// Движок интерактивной карты связей (force-directed graph).
//
// Что делает:
// - держит «мир» (бесконечный холст), по которому можно панорамировать свайпом;
// - раскладывает фокусный узел в центре, остальные — по орбите (радиус зависит от силы связи);
// - по тапу на периферийный узел плавно стягивает его в центр (новый фокус), старый уходит на орбиту;
// - рисует рёбра аналитически (без чтения DOM в цикле) — дёшево для 60 FPS.
//
// Критичные требования (см. план):
// 1) Kill-switch физики: цикл rAF останавливается, когда кинетическая энергия падает ниже порога;
//    просыпается только при взаимодействии (pan / tap / recenter). Батарея не «выжирается».
// 2) Конфликт жестов: любой pan мгновенно прерывает твин центрирования и отдаёт управление пальцу.
//
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё):
//   model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }

import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';

const SVGNS = 'http://www.w3.org/2000/svg';

// --- Параметры физики и анимации ---------------------------------------------
const ORBIT_MIN = 150;          // минимальный радиус орбиты (защитный отступ от центра), px
const ORBIT_MAX = 240;          // максимальный радиус орбиты (слабая связь — дальше), px
const K_RADIAL = 0.035;         // очень мягкая пружина пера к орбите — узлы выходят «как резина»
const K_FOCUS = 0.12;           // мягкая пружина фокуса к центру
const CHARGE = 1400;            // базовое отталкивание (на старте перестроения временно ослабляется)
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
const MIN_DIST = 40;            // минимальная дистанция для расчёта отталкивания
const FRICTION = 0.80;          // базовое затухание (после транзита — лёгкое упругое покачивание)
const FRICTION_BOOST = 0.94;    // «гелевая» вязкость в первые ~700мс после перестроения (гасит «взрыв»)
const BOOST_FRAMES = 42;        // длительность затухающего boost'а вязкости (~700мс @60fps)
const SLEEP_V = 0.03;           // порог суммарной |vx|+|vy| для жёсткой заморозки графа
const INTRO_FACTOR = 0.22;      // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
const PAN_FRICTION = 0.93;      // трение инерционного скролла карты
const TWEEN_MS = 560;           // длительность анимации центрирования (фильтр/фолбэк)
const BLOOM_MS = 900;           // длительность разлёта узлов из центра (физика выключена → ноль тряски)
const BLOOM_STAGGER = 40;       // задержка между «выстреливанием» соседних узлов (волна), мс
const FOCUS_SCALE = 1.5;        // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
const PRIMARY_SCALE = 1.0;      // масштаб обычного узла 1-го уровня
const SECONDARY_SCALE = 0.72;   // масштаб узлов 2-го уровня (друзья друзей)
const PAN_THRESHOLD = 8;        // порог смещения (px), после которого жест считается pan, а не tap
const LONGPRESS_MS = 480;       // порог долгого нажатия
const MAX_FULL_NODES = 90;      // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки

// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
const DEEP2_SCALE = 0.62;       // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем
const DEEP2_OPACITY = 0.85;     // почти непрозрачный — это полноценная аватарка, а не «дырка»
const DEEP3_OPACITY = 0.9;      // 3-й уровень — светящаяся микрозвезда (точка)
const DEEP_R2 = 70;             // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38;             // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px

// Зум камеры (свободное масштабирование колесом мыши / щипком двумя пальцами): мир целиком
// масштабируется CSS-scale (GPU), линии (отдельный SVG-слой) пересчитываются в экранных координатах.
const ZOOM_MIN = 0.55;          // максимальное отдаление
const ZOOM_MAX = 2.6;           // максимальное приближение
const ZOOM_WHEEL = 0.0016;      // чувствительность колеса мыши
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
const EXPAND_REPULSION = 2.4;   // во сколько раз усиливается charge у полностью раскрытого узла
const SPOTLIGHT_DIM = 0.25;     // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
const CAM_GLIDE_K = 0.12;       // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18;    // зазор от края экрана при дотяжке, px

// Умный фокус (Smart Zoom / «аквариум»): клик по узлу 2-го уровня — камера летит и зумит к нему,
// он вырастает в центр; Иван и боковые ветки уменьшаются + расфокус (blur) на задний план;
// нить-крошка обратно к Ивану остаётся яркой. При сильном зуме точки 3-го уровня → аватарки (LOD).
const DIVE_ZOOM = 1.7;          // зум камеры при погружении (наезд ~600мс)
const DIVE_FLY_K = 0.13;        // скорость «полёта» камеры/зума к узлу (lerp за кадр) ≈ 600мс до цели
const HERO_VISUAL = 1.4;        // желаемый ВИДИМЫЙ масштаб нырнутого узла — одинаков для tier-1/2/3 (читаемо)
const DIVE_CHILD_VISUAL = 0.95; // желаемый видимый масштаб его прямых детей (крупно/читаемо)
const DIVE_PATH_MUL = 0.72;     // предки на пути назад — чуть мельче (видимая «цепочка крошек»)
const DIVE_ROOT_MUL = 0.55;     // корень (Иван) уходит вглубь сильнее всех
const DIVE_OFFPATH_MUL = 0.55;  // боковые ветки (вне пути) — уменьшаются на задний план
const DIVE_BLUR = 3;            // размытие фоновых (вне пути) узлов — эффект расфокуса/глубины, px
// LOD с гистерезисом (без «мигания» у порога): апгрейд точка→аватарка на UP, откат на DOWN (зазор).
const LOD_ZOOM_UP = 1.6;        // зум, на котором точки 3-го уровня превращаются в аватарки
const LOD_ZOOM_DOWN = 1.4;      // зум, ниже которого аватарки сворачиваются обратно в точки
const DEEP_FAN = Math.PI * 1.1; // ширина веера детей: полукруг «наружу» от пути назад (~198°)
const DOUBLE_TAP_MS = 320;      // окно двойного тапа по фону (быстрый сброс погружения)

const RELATION_COLORS = {
  family: 'rgba(255, 159, 94, 0.92)',
  friend: 'rgba(120, 179, 255, 0.9)',
  business: 'rgba(190, 150, 255, 0.9)',
  contact: 'rgba(170, 190, 220, 0.7)',
};

// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';


function easeOutCubic(t) {
  const x = 1 - t;
  return 1 - x * x * x;
}

function relationColor(relationType) {
  return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
}

// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
function ensureShineFilter() {
  if (typeof document === 'undefined' || document.getElementById('fg-shine-glow')) return;
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('aria-hidden', 'true');
  svg.setAttribute('width', '0');
  svg.setAttribute('height', '0');
  svg.style.position = 'absolute';
  // + SVG-фильтры размытия для плазменных линий (feGaussianBlur надёжнее CSS-filter на <path> в WebView).
  // Широкий регион фильтра (userSpaceOnUse-подобный запас в %), чтобы размытие не срезалось по bbox пути.
  svg.innerHTML = '<defs>'
    + '<filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></filter>'
    + '<filter id="fg-plasma-blur6" x="-100%" y="-200%" width="300%" height="500%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6"/></filter>'
    + '<filter id="fg-plasma-blur2" x="-60%" y="-120%" width="220%" height="340%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="2"/></filter>'
    + '</defs>';
  document.body.appendChild(svg);
}

// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
function spreadAngle(index, total) {
  if (total <= 0) return 0;
  return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
}

// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
// а не идеальным кругом. Без Math.random: одинаковый узел всегда смещён одинаково.
function hash01(str) {
  let h = 2166136261;
  const s = String(str || '');
  for (let i = 0; i < s.length; i += 1) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return ((h >>> 0) % 1000) / 1000;
}

/**
 * Создаёт движок графа внутри переданного контейнера-сцены.
 * @param {Object} opts
 * @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
 * @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
 * @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
 * @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
 * @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
 * @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
 */
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) {
  // Слои DOM
  const edgesSvg = document.createElementNS(SVGNS, 'svg');
  edgesSvg.setAttribute('class', 'fg-edges');
  const world = document.createElement('div');
  world.className = 'fg-world';
  // Доступность: визуально скрытый текстовый список графа для скринридеров (граф сам по себе им не читается).
  const a11y = document.createElement('div');
  a11y.className = 'fg-a11y';
  a11y.setAttribute('role', 'region');
  a11y.setAttribute('aria-label', 'Карта связей — список');
  stage.append(edgesSvg, world, a11y);
  ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)

  // Состояние камеры (панорамирование + зум)
  let camX = 0;
  let camY = 0;
  let zoom = 1;                 // масштаб камеры (1 = базовый); меняется колесом мыши / щипком
  let camTargetX = null;        // цель дотяжки камеры-доводчика (null = доводчик выключен)
  let camTargetY = null;
  let viewW = stage.clientWidth || window.innerWidth;
  let viewH = stage.clientHeight || window.innerHeight;
  let centerX = viewW / 2;
  let centerY = viewH / 2;

  // Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius }
  let nodes = [];
  let focusId = '';
  let nodeById = new Map();   // id → node (для поиска родителя у глубоких уровней)
  let hasDeep = false;        // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
  let spotActive = false;     // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют)
  let diveTargetId = null;    // id «нырнутого» узла (Smart Zoom); null — мы на верхнем уровне (Иван)
  let diveZoom = 1;           // целевой зум активного погружения
  let surfacing = false;      // идёт «всплытие» назад (камера/зум возвращаются к корню)
  let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания)
  let degreeById = new Map();         // id → число связей узла (для бейджа-счётчика на аватарке)
  const rebuildIndex = () => {
    nodeById = new Map(nodes.map((n) => [String(n.id), n]));
    hasDeep = nodes.some((n) => n.tier >= 2);
    // число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу»)
    childCountByParent = new Map();
    degreeById = new Map();
    let tier1count = 0;
    for (const n of nodes) {
      if (n.tier >= 2 && n.parentId) {
        const i = childCountByParent.get(n.parentId) || 0;
        n.sibIndex = i;
        childCountByParent.set(n.parentId, i + 1);
        degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь
      } else if (n.tier === 1 && String(n.id) !== focusId) {
        tier1count += 1;
      }
    }
    degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня
  };

  // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
  // взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка
  // 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности.
  function rootTier1(n) {
    let r = n; let guard = 0;
    while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null;
    return r;
  }
  function spotTargetOf(n) {
    if (!spotActive || n.isFocus) return 1;
    const r = rootTier1(n);
    if (!r) return SPOTLIGHT_DIM;
    return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM;
  }

  // Путь-«крошки» от корня (Иван) до нырнутого узла включительно — для подсветки нити назад и глубины.
  function divePathSet() {
    const set = new Set();
    if (!diveTargetId) return set;
    let cur = nodeById.get(diveTargetId); let guard = 0;
    while (cur && guard++ < 16) { set.add(String(cur.id)); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
    set.add(String(focusId));
    return set;
  }
  let _pathSet = new Set();
  let _pathSetKey = '';
  function ensurePathSet() {
    const k = diveTargetId || '';
    if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); }
    return _pathSet;
  }
  // Хлебные крошки: упорядоченный путь focus → … → нырнутый узел (для UI-навигации). Пусто = верхний уровень.
  function divePathNodes() {
    const out = [];
    if (!diveTargetId) return out;
    let cur = nodeById.get(diveTargetId); let guard = 0;
    while (cur && guard++ < 16) { out.push(cur); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
    const f = nodeById.get(focusId);
    if (f && out[out.length - 1] !== f) out.push(f);
    return out.reverse(); // от корня (Иван) к цели
  }
  function emitDiveChange() {
    if (typeof onDiveChange !== 'function') return;
    onDiveChange(divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })));
  }
  // Поиск узла по имени/логину (строка поиска): точное совпадение приоритетнее подстроки.
  function findNode(query) {
    const q = String(query || '').trim().toLowerCase();
    if (!q) return null;
    let hit = nodes.find((n) => String(n.name || '').toLowerCase() === q || String(n.login || '').toLowerCase() === q);
    if (!hit) hit = nodes.find((n) => String(n.name || '').toLowerCase().includes(q) || String(n.login || '').toLowerCase().includes(q));
    return hit ? { id: String(hit.id), name: hit.name || hit.login || String(hit.id), tier: hit.tier } : null;
  }
  // Базовый масштаб узла по его роли/уровню (как в makeNodeState) — чтобы привести героя и его детей
  // к ОДИНАКОВОМУ видимому размеру независимо от tier (depthScale = желаемый_видимый / базовый).
  function baseScaleOf(n) {
    if (n.isFocus) return FOCUS_SCALE;
    if (n.tier >= 3) return n.lod === 'full' ? 0.42 : 1;
    if (n.tier === 2) return DEEP2_SCALE;
    return PRIMARY_SCALE;
  }
  // Контекст узла: целевые { прозрачность, множитель масштаба, размытие } для эффекта «аквариума»/spotlight.
  // Погружение (dive) имеет приоритет: нырнутый узел крупный/чёткий, путь назад — чёткий, фон — мелкий/blur.
  function contextTargetOf(n) {
    if (diveTargetId) {
      const ps = ensurePathSet();
      const id = String(n.id);
      // герой и его прямые дети — до фиксированного ВИДИМОГО масштаба (множитель = желаемый/базовый)
      if (id === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.8, HERO_VISUAL / baseScaleOf(n))), blur: 0 };
      if (String(n.parentId) === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.2, DIVE_CHILD_VISUAL / baseScaleOf(n))), blur: 0 };
      if (ps.has(id)) return { op: 1, scale: n.isFocus ? DIVE_ROOT_MUL : DIVE_PATH_MUL, blur: 0 }; // путь назад
      return { op: SPOTLIGHT_DIM, scale: DIVE_OFFPATH_MUL, blur: DIVE_BLUR };            // фон (расфокус)
    }
    return { op: spotTargetOf(n), scale: 1, blur: 0 }; // без погружения — обычный spotlight
  }

  // Управление циклом rAF
  let rafId = 0;
  let dragging = false;

  // Твин центрирования
  let tween = null; // { startTs, from: Map(id->{x,y,scale}), to: Map(id->{x,y,scale}), camFrom, camTo }

  // Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла)
  let pendingFocusOrigin = null;

  // Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра.
  let boost = 0;
  let frictionNow = FRICTION;
  let chargeNow = CHARGE;

  // Режим CSS-bloom: узлы разлетаются нативными CSS-переходами (компоновщик, без JS-физики),
  // цикл только перерисовывает лучи вслед за узлами. Завершается по таймеру.
  let cssBloom = false;
  let cssBloomTimer = 0;
  let cssBloomKind = 'bloom'; // 'bloom' (каскадный разлёт) | 'filter' (фиксация на равномерных углах)

  // Инерция панорамирования (kinematic panning)
  let panVelX = 0;
  let panVelY = 0;

  // Упругий «изгиб от свайпа»: сглаженный вектор, который догоняет скорость пальца и плавно
  // возвращается к нулю при отпускании (lerp). Им смещаем контрольные точки Безье — нити тянутся.
  let panBendX = 0;
  let panBendY = 0;
  let panTwang = false; // флаг «гитарной струны»: один вибро-щелчок при сильном натяжении нитей
  function advancePanBend() {
    panBendX += (panVelX - panBendX) * 0.3;
    panBendY += (panVelY - panBendY) * 0.3;
  }

  // --- Построение модели -----------------------------------------------------
  // Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга:
  // фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки.
  function computeSpecs(srcModel) {
    const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : [];
    const fId = String(srcModel?.focusId || (list[0] && list[0].id) || '');
    // 1-й уровень держит орбиту вокруг центра (сортируем по силе, как раньше); узлы 2/3 уровня
    // («друзья друзей» и микрозвёзды) орбиту не используют — их раскладывает layoutDeep вокруг родителя.
    const tier1 = list
      .filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) === 1)
      .sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0));
    const deep = list.filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) >= 2);
    const dotCount = Math.max(0, tier1.length - MAX_FULL_NODES);
    if (dotCount > 0) {
      console.info(`[force-graph] связей ${tier1.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`);
    }
    const specs = [];
    const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
    if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
    tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
    // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
    deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
    return { focusId: fId, specs };
  }

  function buildNodes(srcModel) {
    const { focusId: fId, specs } = computeSpecs(srcModel);
    focusId = fId;
    return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly));
  }

  function makeNodeState(src, isFocus, index, total, dotOnly = false) {
    const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
    const tier = Number(src.tier) || 1;
    // органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад)
    const jr = (hash01(src.id) - 0.5) * 18;
    const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
    const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
    const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
    // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
    const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
    const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
    // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
    // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
    const tx = isFocus ? 0 : Math.cos(angle) * targetR;
    const ty = isFocus ? 0 : Math.sin(angle) * targetR;
    const el = buildNodeElement(src, isFocus, tier, dotOnly);
    world.append(el);
    return {
      ...src,
      isFocus,
      tier,
      parentId: String(src.parentId || ''),       // у tier≥2 — id родителя; пусто → центр (фокус)
      deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
      track: Boolean(src.track),                   // «трек прохождения» — линия к этому узлу горит ярко
      pinned: false,                               // зафиксировано кликом/тапом — ветка раскрыта «намертво»
      hovered: false,                              // временно раскрыто наведением (мышь/палец) — пропадёт при уходе
      expandP: 0,                                  // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс
      dotOnly,
      strength,
      targetR,
      angle,
      tx,
      ty,
      x: tx * INTRO_FACTOR,
      y: ty * INTRO_FACTOR,
      vx: 0,
      vy: 0,
      scale,
      targetScale: scale,
      hidden: false,
      opacity: tier >= 2 ? op : 1,
      targetOpacity: op,
      spotCur: 1,                                  // текущий множитель spotlight-затемнения (1 = полный свет)
      depthScale: 1,                               // множитель масштаба «глубины» (dive: цель крупно, фон мелко)
      depthBlur: 0,                                // размытие «глубины» (dive: фон уходит в расфокус), px
      lod: (dotOnly && tier >= 3) ? 'dot' : 'full', // уровень детализации tier-3: точка ↔ аватарка (по зуму)
      bloom: false,
      edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
      el,
      dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))),
    };
  }

  // Аватар из прямого URL-фото (тестовые данные лаборатории). Структура — как у renderUserAvatar
  // (переиспользуем CSS .avatar/.node-dot), фолбэк на инициалы при ошибке загрузки (офлайн).
  function buildPhotoAvatar(src) {
    const wrap = document.createElement('div');
    wrap.className = 'avatar avatar-image node-dot';
    const fb = document.createElement('span');
    fb.className = 'avatar-fallback';
    fb.textContent = buildAvatarInitials({ login: src.login || String(src.id), firstName: src.name || '' });
    wrap.append(fb);
    const img = document.createElement('img');
    img.alt = '';
    img.loading = 'lazy';
    img.decoding = 'async';
    img.onload = () => wrap.classList.add('has-image');
    img.onerror = () => { img.remove(); }; // нет сети — остаются инициалы
    img.src = src.photo;
    wrap.append(img);
    return wrap;
  }

  function buildNodeElement(src, isFocus, tier, dotOnly = false) {
    const el = document.createElement('button');
    el.type = 'button';
    // лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
    if (dotOnly) {
      el.className = [
        'fg-node', 'fg-dot',
        tier >= 3 ? 'is-tier3' : '',     // микрозвезда 3-го уровня (светящаяся мерцающая точка)
        src.shining ? 'is-shine' : '',
        `is-${src.relationType || 'contact'}`,
      ].filter(Boolean).join(' ');
      el.dataset.nodeId = String(src.id);
      el.title = src.name || src.login || '';
      // десинхронизируем мерцание звёзд (отрицательная задержка) → живое «созвездие», не «моргание в такт»
      if (tier >= 3) el.style.animationDelay = `${(-hash01(`${src.id}~t`) * 3.4).toFixed(2)}s`;
      return el;
    }
    el.className = [
      'fg-node',
      isFocus ? 'is-focus' : '',
      src.shining ? 'is-shine' : '',
      `is-${src.relationType || 'contact'}`,
      tier === 2 ? 'is-tier2' : '',      // друг друзей (вдвое меньше, полупрозрачный)
      tier >= 2 ? 'is-secondary' : '',
      src.common ? 'is-common' : '',     // «общая связь» — этот человек и твой друг тоже (золотой ободок ★)
    ].filter(Boolean).join(' ');
    el.dataset.nodeId = String(src.id);

    // тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы)
    const avatar = src.photo
      ? buildPhotoAvatar(src)
      : renderUserAvatar({
        login: src.login || src.name || String(src.id),
        firstName: src.name || '',
        avatar: src.avatar || null,
        size: 'node',
        title: src.name || src.login || '',
      });
    el.append(avatar);

    // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
    const badge = document.createElement('span');
    badge.className = 'fg-node-badge';
    badge.hidden = true;
    el.append(badge);

    const label = document.createElement('span');
    label.className = 'fg-node-label';
    label.textContent = src.name || src.login || '';
    el.append(label);
    return el;
  }

  // Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex.
  function updateBadges() {
    for (const n of nodes) {
      const badge = n.el.querySelector('.fg-node-badge');
      if (!badge) continue; // у точек (dotOnly) бейджа нет
      const deg = degreeById.get(String(n.id)) || 0;
      if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; }
      else { badge.hidden = true; }
    }
  }

  // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком).
  function updateA11y() {
    const focus = nodes.find((n) => n.isFocus);
    const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
    const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' };
    const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
    a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
  }
  function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }

  // Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель.
  function updateNodeRole(node, spec) {
    const src = spec.src;
    const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
    const tier = Number(src.tier) || 1;
    node.isFocus = spec.isFocus;
    node.tier = tier;
    node.parentId = String(src.parentId || '');
    node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
    node.track = Boolean(src.track);
    node.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается
    node.depthScale = 1; node.depthBlur = 0; node.lod = (spec.dotOnly && tier >= 3) ? 'dot' : 'full';
    node.dotOnly = spec.dotOnly;
    node.strength = strength;
    node.relationType = src.relationType;
    node.shining = Boolean(src.shining);
    const jr = (hash01(src.id) - 0.5) * 18;
    const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
    node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
    node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
    node.tx = Math.cos(node.angle) * node.targetR;
    node.ty = Math.sin(node.angle) * node.targetR;
    node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
    node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
    node.hidden = false;
    node.bloom = false;
    node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
    // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
    node.el.className = spec.dotOnly
      ? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
      : ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
  }

  // --- Рендер ----------------------------------------------------------------
  function applyWorldTransform() {
    world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
  }

  // Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
  // в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
  // упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
  function glideCameraTo(n) {
    if (!n) return;
    const ring = (DEEP_R2 + 64) * zoom;            // радиус раскрытого веера + запас (экранные px)
    const sx = centerX + camX + n.x * zoom;
    const sy = centerY + camY + n.y * zoom;
    let tx = camX;
    let ty = camY;
    const m = CAM_GLIDE_MARGIN;
    if (sx - ring < m) tx += (m - (sx - ring));
    else if (sx + ring > viewW - m) tx -= ((sx + ring) - (viewW - m));
    if (sy - ring < m) ty += (m - (sy - ring));
    else if (sy + ring > viewH - m) ty -= ((sy + ring) - (viewH - m));
    if (Math.abs(tx - camX) > 1 || Math.abs(ty - camY) > 1) { camTargetX = tx; camTargetY = ty; }
  }

  // --- Зум камеры (колесо мыши / щипок двумя пальцами) -----------------------
  // Масштабируем «к точке» (sx,sy): мировая точка под курсором/центром щипка остаётся на месте.
  function setZoom(nextZoom, sx, sy) {
    const z0 = zoom;
    const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
    if (Math.abs(z1 - z0) < 0.0001) return;
    const wx = (sx - centerX - camX) / z0;
    const wy = (sy - centerY - camY) / z0;
    zoom = z1;
    camX = sx - centerX - wx * z1;
    camY = sy - centerY - wy * z1;
    camTargetX = null; camTargetY = null;          // ручной зум отменяет доводчик
    applyWorldTransform();
    renderEdges();
  }
  function onWheel(ev) {
    ev.preventDefault();
    const rect = stage.getBoundingClientRect();
    setZoom(zoom * Math.exp(-ev.deltaY * ZOOM_WHEEL), ev.clientX - rect.left, ev.clientY - rect.top);
    wake();
  }

  function renderNodes() {
    for (const n of nodes) {
      const ds = n.depthScale ?? 1;
      n.el.style.transform =
        `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
      n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
      // расфокус глубины (dive): фоновые узлы уходят в blur; чёткие — без фильтра (дёшево)
      n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
      n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
    }
  }

  // Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию
  // СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия
  // (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь.
  function layoutDeep() {
    if (!hasDeep) return;
    for (const tier of [2, 3]) {
      for (const n of nodes) {
        if (n.tier !== tier) continue;
        const p = nodeById.get(n.parentId);
        if (!p) { n.opacity = 0; continue; }
        const e = p.expandP || 0;                      // насколько раскрыт родитель
        // АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель
        // и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей.
        const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
        const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
        const cnt = childCountByParent.get(n.parentId) || 1;
        const ringR = Math.max(baseR + pr, cnt * 13);  // расталкивание: дети без наложений (клиренс + место)
        const r = ringR * e;                            // при e=0 — в центре родителя, при 1 — на полной орбите
        // ВЕЕР «полукругом наружу»: раскрываем детей в сторону ОТ пути назад (от деда к родителю),
        // чтобы они не перекрывали нить-крошку и родителя. Равномерно по индексу среди братьев.
        const gp = nodeById.get(p.parentId);
        const ox = p.x - (gp ? gp.x : 0);
        const oy = p.y - (gp ? gp.y : 0);
        const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол)
        const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0;  // -0.5..0.5 по веера
        const ang = outward + t * DEEP_FAN;
        n.x = p.x + Math.cos(ang) * r;
        n.y = p.y + Math.sin(ang) * r;
        const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
        // tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42)
        const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
        // вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий)
        const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1;
        n.opacity = baseOp * e * parentVis;
        n.scale = baseSc * (0.3 + 0.7 * e);            // лёгкий «поп» при выплывании
        n.targetOpacity = n.opacity; n.targetScale = n.scale;
      }
    }
  }

  // Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет.
  // Эффективная цель раскрытия узла: раскрыт, если зафиксирован кликом (pinned) ИЛИ временно наведён (hovered).
  function expandTargetOf(n) { return (n.pinned || n.hovered) ? 1 : 0; }

  function advanceExpand() {
    let moving = false;
    for (const n of nodes) {
      const t = expandTargetOf(n);
      const cur = n.expandP || 0;
      if (Math.abs(cur - t) > 0.001) {
        let next = cur + (t - cur) * 0.18;
        if (Math.abs(next - t) < 0.012) next = t; else moving = true;
        n.expandP = next;
      }
    }
    return moving;
  }

  // Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик)
  // отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями.
  function renderDeepNodes() {
    if (!hasDeep) return;
    for (const n of nodes) {
      if (n.tier < 2) continue;
      const ds = n.depthScale ?? 1;
      n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
      n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
      n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
    }
  }

  // LOD (level-of-detail): при сильном зуме (pinch/dive) видимые точки 3-го уровня дорисовываются как
  // маленькие аватарки (лицо+имя), при отдалении — сворачиваются обратно в светящиеся точки.
  // Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая.
  function updateLod() {
    if (!hasDeep) return;
    for (const n of nodes) {
      if (n.tier < 3) continue;                          // LOD касается только микрозвёзд 3-го уровня
      const isFull = n.lod === 'full';
      // гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания)
      const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP;
      const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые
      if (wantFull === isFull) continue;
      setNodeLod(n, wantFull);
    }
  }
  function setNodeLod(n, full) {
    const newEl = buildNodeElement(n, false, n.tier, !full); // n несёт src-поля (photo/name/...) через spread
    newEl.style.transform = n.el.style.transform;          // без скачка к (0,0) на один кадр
    newEl.style.opacity = n.el.style.opacity;
    world.replaceChild(newEl, n.el);
    n.el = newEl;
    n.lod = full ? 'full' : 'dot';
    n.dotOnly = !full;
    n.dotRadius = full ? 12 : 5;                           // радиус для расчёта концов линий связей
    if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } }
  }

  function renderEdges() {
    const focus = nodes.find((n) => n.id === focusId);
    if (!focus) {
      edgesSvg.innerHTML = '';
      return;
    }
    // концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
    // Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
    const Z = zoom;
    const tx = (n) => centerX + camX + n.x * Z;
    const ty = (n) => centerY + camY + n.y * Z;

    const focusLogin = String(focus.login || '').toLowerCase();
    const parts = [];
    const defs = [];
    let gi = 0;
    for (const n of nodes) {
      if (n === focus) continue;
      // скрытый фильтром узел: рисуем луч пока он гаснет (живая прозрачность > 0), затем пропускаем
      const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1;
      if (n.hidden && nodeOpacity <= 0.02) continue;
      if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
      // начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель
      const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
      const fx = centerX + camX + parent.x * Z;
      const fy = centerY + camY + parent.y * Z;
      const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4;
      const nx = tx(n);
      const ny = ty(n);
      if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
      if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;

      // Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке
      // и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
      // общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
      const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
      const ex = growing ? (centerX + camX + n.bfx * Z) : nx;
      const ey = growing ? (centerY + camY + n.bfy * Z) : ny;
      const dx = ex - fx;
      const dy = ey - fy;
      const len = Math.hypot(dx, dy) || 1;
      const ux = dx / len;
      const uy = dy / len;
      const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4;
      // концы линии — у краёв кружков
      const x1 = fx + ux * fr;
      const y1 = fy + uy * fr;
      const x2 = ex - ux * nr;
      const y2 = ey - uy * nr;
      // контрольная точка кривой Безье: постоянная изящная дуга (перпендикуляр) +
      // прогиб НАЗАД против вектора скорости узла (резиновый жгут); в покое — идеальная дуга
      const mx = (x1 + x2) / 2;
      const my = (y1 + y2) / 2;
      const segLen0 = Math.hypot(x2 - x1, y2 - y1);
      const baseBow = Math.max(7, Math.min(20, segLen0 * 0.12));   // постоянная дуга
      const speed = Math.hypot(n.vx, n.vy);
      const lag = Math.min(30, speed * 1.8);                       // отставание ∝ скорости
      const invX = speed > 0.01 ? -n.vx / speed : 0;               // направление против движения
      const invY = speed > 0.01 ? -n.vy / speed : 0;
      // ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца,
      // сильнее у дальних узлов (инерция периферии). panBend сглажен и сам затухает → нить пружинит назад.
      const farK = Math.min(1.3, Math.max(0.35, segLen0 / 200));
      let panBx = -panBendX * 0.55 * farK;
      let panBy = -panBendY * 0.55 * farK;
      const panBmag = Math.hypot(panBx, panBy);
      if (panBmag > 40) { panBx = (panBx / panBmag) * 40; panBy = (panBy / panBmag) * 40; }
      // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости узла + пан-стретч
      const desX = mx + (-uy) * baseBow + invX * lag + panBx;
      const desY = my + ux * baseBow + invY * lag + panBy;
      const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
      const cpy = 2 * desY - my;
      // Связь рисуем по статусу узла:
      //  • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
      //  • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
      //    core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
      const shine = Boolean(n.shining) && !n.hidden;
      const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом
      const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути
      const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
      // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
      // (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
      let dashAttr = '';
      if (growing) {
        const finalD = Math.hypot(n.bfx, n.bfy) || 1;
        const curD = Math.hypot(n.x, n.y);
        const growP = Math.max(0, Math.min(1, curD / finalD));
        const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
        dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
      }
      const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
      if (n.tier >= 3) {
        // 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
        if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe * sp).toFixed(2)}" />`);
      } else if (n.tier === 2) {
        // 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
        if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe * sp).toFixed(2)}" />`);
      } else if (shine || n.track || onPath) {
        // СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя
        // с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро.
        const pnx = -uy;
        const pny = ux;                                   // перпендикуляр к хорде
        const amp = Math.min(13, 5 + segLen0 * 0.05);     // амплитуда S — спокойная изящная волна (∝ длине)
        const bowX = desX - mx;
        const bowY = desY - my;                           // вектор изящной дуги + пан-стретч — сохраняем
        // единственная S-кривая: контрольная точка на 1/3 смещена +amp, на 2/3 — −amp (плавная волна)
        const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp;
        const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp;
        const dS = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
        const base = nodeOpacity * sp;                    // затемнение от spotlight/глубины
        // 3 слоя на ОДНОМ пути dS. Видимый НЕОН: яркое ядро + чёткий голубой ореол (поле+трубка в режиме
        // screen — свет складывается аддитивно с фоном). Прозрачности подняты, чтобы не было «белого каната».
        parts.push(`<path class="fg-plasma-flare" d="${dS}"${dashAttr} opacity="${(0.42 * base).toFixed(3)}" />`);
        parts.push(`<path class="fg-plasma-tube" d="${dS}"${dashAttr} opacity="${(0.85 * base).toFixed(3)}" />`);
        parts.push(`<path class="fg-plasma-core" d="${dS}"${dashAttr} opacity="${(1 * base).toFixed(3)}" />`);
      } else {
        // ОБЫЧНАЯ связь — мягкий светящийся жгут В ЦВЕТЕ СВЯЗИ (семья тёплый / друзья синий /
        // бизнес фиолет): тоньше и тише сияющих. Без SVG-blur (дёшево): широкая подложка + тонкое ядро.
        const col = relationColor(n.relationType);
        const opVal = nodeOpacity * sp;
        const haloOp = (0.22 * opVal).toFixed(2);
        const coreOp = (0.7 * opVal).toFixed(2);
        const sw = (2.6 + n.strength * 1.4).toFixed(2);   // мягкая подложка-«свечение» в цвете связи
        parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" opacity="${haloOp}"${dashAttr} />`);
        parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="1.2" stroke-linecap="round" opacity="${coreOp}"${dashAttr} />`);
      }
    }
    edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
  }

  function renderAll() {
    renderNodes();
    renderEdges();
  }

  // --- Физика (пружины + отталкивание) ---------------------------------------
  // Фокус не «пинится» жёстко, а влетает к центру пружиной (упругая стабилизация).
  // Периферия держится радиальной пружиной на орбите и расталкивается силой charge —
  // получается органичная плавающая структура, а не жёсткий круг.
  function stepPhysics() {
    let totalV = 0;
    for (const n of nodes) {
      if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает
      if (n.tier >= 2) { n.vx = 0; n.vy = 0; continue; } // глубокие уровни — раскладывает layoutDeep
      let ax = 0;
      let ay = 0;

      if (n.isFocus) {
        // пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается)
        ax += K_FOCUS * (0 - n.x);
        ay += K_FOCUS * (0 - n.y);
      } else {
        // радиальная пружина к целевому радиусу орбиты
        const d = Math.hypot(n.x, n.y) || 0.001;
        const ux = n.x / d;
        const uy = n.y / d;
        const fr = K_RADIAL * (n.targetR - d);
        ax += fr * ux;
        ay += fr * uy;
        // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре);
        // глубокие уровни (tier≥2) в отталкивании не участвуют (их много — берегём перф)
        for (const m of nodes) {
          if (m === n || m.hidden || m.tier >= 2) continue;
          const dx = n.x - m.x;
          const dy = n.y - m.y;
          let dist2 = dx * dx + dy * dy;
          if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
          const dist = Math.sqrt(dist2);
          // адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
          // пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
          const ex = Math.max(n.expandP || 0, m.expandP || 0);
          const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
          ax += (dx / dist) * f;
          ay += (dy / dist) * f;
        }
      }

      n.vx = (n.vx + ax) * frictionNow;
      n.vy = (n.vy + ay) * frictionNow;
      n.x += n.vx;
      n.y += n.vy;
      totalV += Math.abs(n.vx) + Math.abs(n.vy);
    }
    return totalV;
  }

  // Плавное приближение масштаба/прозрачности к целям + рост линии + spotlight + глубина (dive).
  function advanceVisual() {
    spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют
    for (const n of nodes) {
      n.scale += (n.targetScale - n.scale) * 0.2;
      n.opacity += (n.targetOpacity - n.opacity) * 0.2;
      const c = contextTargetOf(n);                    // {op, scale, blur} — spotlight или глубина dive
      n.spotCur += (c.op - n.spotCur) * 0.2;           // затемнение/прояснение
      n.depthScale += (c.scale - n.depthScale) * 0.2;  // dive: цель крупно / фон мелко
      n.depthBlur += (c.blur - n.depthBlur) * 0.2;     // dive: фон уходит в расфокус
      // линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
      if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
    }
  }

  // Не «успокоились» ли ещё визуальные параметры/рост линий/глубина (для условия заморозки).
  function visualSettling() {
    for (const n of nodes) {
      const c = contextTargetOf(n);
      if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01
        || Math.abs(n.spotCur - c.op) > 0.01 || Math.abs(n.depthScale - c.scale) > 0.01
        || Math.abs(n.depthBlur - c.blur) > 0.4 || n.edgeGrow < 1) return true;
    }
    return false;
  }

  // --- Твин центрирования -----------------------------------------------------
  function startRecenterTween(newFocusId) {
    const target = nodes.find((n) => String(n.id) === String(newFocusId));
    if (!target || target.isFocus) return;

    focusId = String(newFocusId);
    // пересчёт ролей: новый фокус — в центр, остальные — на орбиту (включая старый фокус)
    const peers = nodes.filter((n) => String(n.id) !== focusId);
    const from = new Map();
    const to = new Map();
    nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale }));

    nodes.forEach((n) => {
      n.isFocus = String(n.id) === focusId;
      n.el.classList.toggle('is-focus', n.isFocus);
    });

    target.targetR = 0;
    target.tx = 0;
    target.ty = 0;
    target.vx = 0;
    target.vy = 0;
    to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });

    peers.forEach((n, i) => {
      n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
      n.angle = spreadAngle(i, peers.length);
      n.vx = 0;
      n.vy = 0;
      const tx = Math.cos(n.angle) * n.targetR;
      const ty = Math.sin(n.angle) * n.targetR;
      n.tx = tx;             // обновляем целевую точку, иначе физика после твина утянет узел назад
      n.ty = ty;
      const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
      to.set(n.id, { x: tx, y: ty, scale: sc });
    });

    tween = {
      startTs: 0,
      from,
      to,
      camFrom: { x: camX, y: camY },
      camTo: { x: 0, y: 0 },
    };
    wake();
  }

  // Универсальный твин (физика выключена → ноль тряски). Поддерживает длительность, кривую и
  // поканальную задержку (каскад) + рост линий. Используется для bloom-разлёта, фильтра, центрирования.
  function stepTween(ts) {
    if (!tween.startTs) tween.startTs = ts;
    const dur = tween.dur || TWEEN_MS;
    const ease = tween.ease || easeOutCubic;
    const elapsed = ts - tween.startTs;
    let allDone = true;
    for (const n of nodes) {
      const a = tween.from.get(n.id);
      const b = tween.to.get(n.id);
      if (!a || !b) continue;
      let raw = (elapsed - (a.delay || 0)) / dur;
      if (raw < 0) raw = 0;        // узел ещё не «выпущен» — держим в стартовой точке
      if (raw < 1) allDone = false;
      raw = Math.min(1, raw);
      const t = ease(raw);
      n.x = a.x + (b.x - a.x) * t;
      n.y = a.y + (b.y - a.y) * t;
      n.scale = a.scale + (b.scale - a.scale) * t;
      const ao = a.opacity ?? 1;
      const bo = b.opacity ?? 1;
      n.opacity = ao + (bo - ao) * t;
      if (b.grow) n.edgeGrow = raw; // линия «вытекает» по прогрессу своего узла
    }
    const camT = ease(Math.min(1, elapsed / dur));
    camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * camT;
    camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * camT;
    applyWorldTransform();
    if (allDone) {
      const wasBloom = tween.idleBoost;
      tween = null; // твин завершён
      for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; n.edgeGrow = 1; }
      if (wasBloom) boost = 1; // в покое — лёгкое «гель»-демпфированное упругое покачивание
    }
  }

  // Прерывание твина жестом (требование «конфликт жестов»): фиксируем текущие позиции и отдаём пальцу.
  function cancelTween() {
    if (!tween) return;
    tween = null;
    for (const n of nodes) { n.vx = 0; n.vy = 0; }
  }

  // Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
  // Перестроение идёт нативными CSS-переходами (компоновщик): работает даже когда rAF-цикл
  // троттлится в простое (иначе граф «не перестраивался» бы). Скрытые узлы плавно гаснут НА МЕСТЕ
  // (scale 0.8 + opacity 0 за 300мс), видимые плавно переплывают на равномерные углы орбиты;
  // лучи скрываемых гаснут вместе с ними (renderEdges читает живую прозрачность из DOM).
  // По завершении — жёсткая фиксация на этих углах БЕЗ физики (ноль тряски, идеальный sleep).
  function setFilter(predicate) {
    const pred = typeof predicate === 'function' ? predicate : () => true;
    if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; }
    cancelTween(); // на случай активного JS-твина центрирования — отдаём управление CSS-переходу

    const visiblePeers = [];
    nodes.forEach((n) => {
      if (n.isFocus) { n.hidden = false; return; }
      n.hidden = !pred(n);
      n.vx = 0;
      n.vy = 0;
      if (!n.hidden) visiblePeers.push(n);
    });

    const FILTER_MS = 300;
    const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`;
    // применяем целевое состояние как CSS-переход; финал кэшируем в bfx/bfy/bfs/bfo для endCssBloom
    const apply = (n, x, y, s, o) => {
      n.bfx = x; n.bfy = y; n.bfs = s; n.bfo = o;
      n.el.style.transition = `transform ${FILTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${FILTER_MS}ms ease`;
      n.el.style.transform = tf(x, y, s);
      n.el.style.opacity = String(o);
      n.el.style.pointerEvents = o <= 0.01 ? 'none' : '';
    };

    const focus = nodes.find((n) => n.isFocus);
    if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);

    visiblePeers.forEach((n, i) => {
      n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
      n.angle = spreadAngle(i, visiblePeers.length);
      n.tx = Math.cos(n.angle) * n.targetR;
      n.ty = Math.sin(n.angle) * n.targetR;
      const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
      apply(n, n.tx, n.ty, sc, 1);
    });

    nodes.forEach((n) => {
      if (n.isFocus || !n.hidden) return;
      // скрытые: растворяются ПРЯМО НА МЕСТЕ (scale 0.8 + opacity 0 за 300мс) — мягкий фейд, без «вылетов»
      apply(n, n.x, n.y, 0.8, 0);
    });

    // режим CSS-перехода: цикл лишь ведёт лучи за узлами; по таймеру — фиксация без физики
    cssBloom = true;
    cssBloomKind = 'filter';
    renderEdges();
    cssBloomTimer = window.setTimeout(endCssBloom, FILTER_MS + 60);
    wake();
  }

  // Жёсткая заморозка: гасим скорости, округляем координаты до целых пикселей,
  // НЕ перезапускаем цикл — граф замирает намертво (без «треска»).
  function freezeGraph() {
    for (const n of nodes) {
      n.vx = 0;
      n.vy = 0;
      n.x = Math.round(n.x);
      n.y = Math.round(n.y);
      n.scale = n.targetScale;
      n.opacity = n.targetOpacity;
    }
    panBendX = 0; panBendY = 0; // нити в покое — ровные базовые дуги
    layoutDeep(); // глубокие уровни — на орбитах родителей перед финальным кадром
    renderAll(); // финальный кадр на целых координатах
  }

  // --- Цикл с kill-switch + инерция + заморозка ------------------------------
  function tick(ts) {
    rafId = 0;

    // режим CSS-bloom: узлы 1-го уровня анимирует компоновщик — мы лишь ведём лучи за ними (без физики);
    // глубокие уровни (tier≥2) не в CSS-bloom — позиционируем их из JS, чтобы следовали за родителями
    if (cssBloom) {
      syncPositionsFromDOM();
      advanceExpand();
      layoutDeep();
      renderDeepNodes();
      renderEdges();
      schedule();
      return;
    }

    // инерция панорамирования (kinematic): камера докатывается с трением
    const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
    if (panActive) {
      camX += panVelX;
      camY += panVelY;
      panVelX *= PAN_FRICTION;
      panVelY *= PAN_FRICTION;
      applyWorldTransform();
    } else if (dragging) {
      // палец удерживается без движения → скорость (и изгиб нитей) мягко расслабляется,
      // но onPointerMove перезапишет её свежим дельтой при следующем движении
      panVelX *= 0.7;
      panVelY *= 0.7;
    } else {
      panVelX = 0;
      panVelY = 0;
    }
    advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю
    // «гитарная струна»: один короткий вибро-щелчок при сильном натяжении нитей свайпом
    const pbMag = Math.abs(panBendX) + Math.abs(panBendY);
    if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); }
    else if (pbMag < 12) panTwang = false;

    // камера-доводчик: плавно дотягиваем камеру к цели (после раскрытия ветки). Жест уже обнулил цель.
    let camGliding = false;
    if (camTargetX !== null && !dragging && !panActive) {
      camX += (camTargetX - camX) * CAM_GLIDE_K;
      camY += (camTargetY - camY) * CAM_GLIDE_K;
      if (Math.abs(camTargetX - camX) < 0.5 && Math.abs(camTargetY - camY) < 0.5) {
        camX = camTargetX; camY = camTargetY; camTargetX = null; camTargetY = null;
      } else { camGliding = true; }
      applyWorldTransform();
    }

    // Smart Zoom: dive-камера летит и зумит к нырнутому узлу (аквариумный наезд), центрируя его;
    // всплытие (surfacing) — плавный возврат камеры/зума к корню. Любой жест (drag/pan/pinch) приостанавливает.
    let diveCamActive = false;
    if (!dragging && !panActive && !pinching) {
      if (diveTargetId) {
        const t = nodeById.get(diveTargetId);
        if (t) {
          zoom += (diveZoom - zoom) * DIVE_FLY_K;
          const desX = -t.x * zoom; // узел → центр экрана (screen = center + cam + x*zoom = center)
          const desY = -t.y * zoom;
          camX += (desX - camX) * DIVE_FLY_K;
          camY += (desY - camY) * DIVE_FLY_K;
          applyWorldTransform();
          if (Math.abs(zoom - diveZoom) > 0.004 || Math.abs(desX - camX) > 0.4 || Math.abs(desY - camY) > 0.4) diveCamActive = true;
        }
      } else if (surfacing) {
        zoom += (1 - zoom) * DIVE_FLY_K;
        camX += (0 - camX) * DIVE_FLY_K;
        camY += (0 - camY) * DIVE_FLY_K;
        applyWorldTransform();
        if (Math.abs(zoom - 1) < 0.004 && Math.abs(camX) < 0.4 && Math.abs(camY) < 0.4) { zoom = 1; camX = 0; camY = 0; surfacing = false; }
        else diveCamActive = true;
      }
    }
    updateLod(); // LOD: точки 3-го уровня ↔ аватарки по текущему зуму

    // динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
    // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
    frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
    chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost);
    if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES);

    let totalV = 0;
    if (tween) {
      stepTween(ts);
    } else {
      totalV = stepPhysics();
      advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity
    }

    const expanding = advanceExpand(); // раскрытие/схлопывание глубоких уровней (по клику/ховеру)
    layoutDeep(); // глубокие уровни следуют за родителями (после шага физики 1-го уровня)
    renderAll();

    const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
    if (tween || dragging || panActive || camGliding || diveCamActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
      schedule();
    } else {
      freezeGraph(); // система успокоилась — замираем
    }
  }

  function schedule() {
    if (!rafId) rafId = requestAnimationFrame(tick);
  }

  function wake() {
    schedule();
  }

  // --- Жесты (pan / tap / longpress) -----------------------------------------
  let pointerId = null;
  let downX = 0;
  let downY = 0;
  let camStartX = 0;
  let camStartY = 0;
  let moved = false;
  let downNodeEl = null;
  let longTimer = 0;
  let longFired = false;
  const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
  let pinching = false;             // активен щипок (pan/tap на это время заморожены)
  let pinchDist0 = 0;               // базовая дистанция между пальцами (px) для расчёта масштаба
  let hoverNode = null;             // узел под курсором мыши (для ховер-раскрытия ветки)
  let lastBgTapTs = 0;              // время последнего тапа по пустому фону (для двойного тапа = сброс)
  // Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
  const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };

  // Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера.
  const prefetched = new Set();
  function prefetchChildren(node) {
    if (!node) return;
    const pid = String(node.id);
    for (const m of nodes) {
      if (String(m.parentId) === pid && m.photo && !prefetched.has(m.photo)) {
        prefetched.add(m.photo);
        try { const im = new Image(); im.decoding = 'async'; im.src = m.photo; } catch { /* нет Image — не критично */ }
      }
    }
  }

  // Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см.
  // advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение).
  // Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся
  // раскрытой, даже когда убрали палец/мышь (в отличие от ховера). + камера-доводчик подводит кластер в кадр.
  function toggleExpand(node) {
    const n = node && (nodeById.get(String(node.id)) || node);
    if (!n) return;
    // надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером).
    const isOpen = n.pinned || (n.expandP || 0) > 0.5;
    if (isOpen) {
      n.pinned = false; n.hovered = false;       // свернуть полностью
      haptic(8);
    } else {
      n.pinned = true; haptic([10, 25, 6, 35, 3]); glideCameraTo(n); // раскрыть + дотяжка камеры
    }
    wake();
  }
  // Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
  // над узлом, и втягивается при уходе (если узел не закреплён кликом). node=null — снять ховер со всех.
  function setHover(node) {
    const target = node && (nodeById.get(String(node.id)) || node);
    let changed = false;
    for (const n of nodes) {
      const want = n === target;
      if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; }
    }
    if (target) prefetchChildren(target); // подгружаем лица детей заранее (до клика)
    if (changed) wake();
  }
  // Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток + всплываем из погружения (тап по Ивану).
  function collapseAll() {
    let any = false;
    for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } }
    if (diveTargetId) { diveTargetId = null; surfacing = true; any = true; } // всплыть наверх
    if (any) haptic(14);
    emitDiveChange(); // крошки → верхний уровень
    wake();
  }

  // Умный фокус (Smart Zoom): погружение в узел 2-го+ уровня. Камера летит/зумит к нему (в tick),
  // узел вырастает в центр, путь назад к Ивану остаётся ярким, фон уходит в расфокус (см. contextTargetOf).
  function diveTo(node) {
    const n = node && (nodeById.get(String(node.id)) || node);
    if (!n || n.isFocus) return;                          // в сам фокус (Иван) не ныряем
    if (diveTargetId === String(n.id)) { exitDive(); return; } // повтор по цели — всплыть назад (полный сброс)
    // ЕДИНЫЙ активный путь (железный spotlight): гасим ВСЕ прежние фиксации/ховеры, затем раскрываем
    // только путь к новой цели (предки до Ивана) — чтобы цель и её дети были видимы, прочее не «копилось».
    for (const m of nodes) { m.pinned = false; m.hovered = false; }
    let cur = n; let guard = 0;
    while (cur && guard++ < 16) { if (!cur.isFocus) cur.pinned = true; if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
    diveTargetId = String(n.id);
    diveZoom = DIVE_ZOOM;
    surfacing = false;
    camTargetX = null; camTargetY = null;                 // dive-камера центрирует сама
    prefetchChildren(n);                                  // подгружаем лица детей заранее
    haptic([12, 30, 8, 40]);                              // «погружение» — нарастающий импульс
    emitDiveChange();                                     // обновляем хлебные крошки (Иван › … › цель)
    wake();
  }
  // Всплытие/закрытие ветки: ПОЛНЫЙ сброс — снимаем все фиксации/ховеры (дети втягиваются),
  // камера и зум плавно возвращаются на весь граф, ВСЕ узлы гарантированно вернут opacity 1.
  function exitDive() {
    for (const m of nodes) { m.pinned = false; m.hovered = false; }
    diveTargetId = null;
    surfacing = true;
    haptic(10);
    emitDiveChange();                                     // крошки → верхний уровень
    wake();
  }

  function nodeFromEvent(ev) {
    const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
    if (!el) return null;
    const id = el.dataset.nodeId;
    return nodes.find((n) => String(n.id) === String(id)) || null;
  }

  function onPointerDown(ev) {
    activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
    // второй палец → режим щипкового зума: прерываем pan/tap/longpress, фиксируем базовую дистанцию
    if (activePointers.size === 2) {
      pinching = true;
      camTargetX = null; camTargetY = null;
      if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
      if (downNodeEl) { downNodeEl.classList.remove('is-pressed'); }
      dragging = false;
      const pts = [...activePointers.values()];
      pinchDist0 = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
      return;
    }
    if (pointerId !== null) return;
    pointerId = ev.pointerId;
    panVelX = 0; // новое касание мгновенно прерывает инерцию
    panVelY = 0;
    camTargetX = null; camTargetY = null; // касание отменяет доводчик камеры
    try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ }
    downX = ev.clientX;
    downY = ev.clientY;
    camStartX = camX;
    camStartY = camY;
    moved = false;
    longFired = false;
    downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
    if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
    const downNode = nodeFromEvent(ev);
    // касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
    if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
    if (downNode && typeof onNodeLongPress === 'function') {
      longTimer = window.setTimeout(() => {
        if (moved) return;
        longFired = true;
        const rect = downNode.el.getBoundingClientRect();
        // координаты для меню берём из экранного rect узла (меню рендерится вне масштабируемого мира)
        onNodeLongPress(downNode, { x: rect.left + rect.width / 2, y: rect.top, rect });
      }, LONGPRESS_MS);
    }
  }

  // Ховер мышью: наведение на узел → превью ветки; уход с узла → сворачивание (если не закреплён кликом).
  function onPointerOver(ev) {
    if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
    const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
    const node = el ? nodes.find((n) => String(n.id) === String(el.dataset.nodeId)) : null;
    if (node && node !== hoverNode) { hoverNode = node; onNodeHover(node, true); }
  }
  function onPointerOut(ev) {
    if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
    const toEl = ev.relatedTarget instanceof Element ? ev.relatedTarget.closest('.fg-node') : null;
    if (toEl && hoverNode && String(toEl.dataset.nodeId) === String(hoverNode.id)) return; // ещё внутри узла
    if (hoverNode) { hoverNode = null; onNodeHover(null, false); }
  }

  function onPointerMove(ev) {
    if (activePointers.has(ev.pointerId)) activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
    // щипок двумя пальцами: масштабируем относительно центра между пальцами (зум «к точке»)
    if (pinching && activePointers.size >= 2) {
      const pts = [...activePointers.values()];
      const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
      const rect = stage.getBoundingClientRect();
      const mx = (pts[0].x + pts[1].x) / 2 - rect.left;
      const my = (pts[0].y + pts[1].y) / 2 - rect.top;
      const prevDist = pinchDist0;
      setZoom(zoom * (dist / pinchDist0), mx, my);
      pinchDist0 = dist;
      // сильный pinch-out (пальцы сходятся) на минимальном зуме во время погружения = всплыть назад
      if (diveTargetId && dist < prevDist && zoom <= ZOOM_MIN + 0.02) exitDive();
      wake();
      return;
    }
    if (ev.pointerId !== pointerId) return;
    const dx = ev.clientX - downX;
    const dy = ev.clientY - downY;
    if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
      moved = true;
      if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
      if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
      // палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
      if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
      camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
      cancelTween();          // жест прерывает анимацию центрирования
      dragging = true;
    }
    if (moved) {
      const newCamX = camStartX + dx;
      const newCamY = camStartY + dy;
      panVelX = newCamX - camX;  // мгновенная скорость свайпа (для инерции после отпускания)
      panVelY = newCamY - camY;
      camX = newCamX;
      camY = newCamY;
      applyWorldTransform();
      advancePanBend();       // упругий изгиб нитей догоняет палец во время свайпа
      renderEdges();          // рёбра следуют за камерой синхронно (дёшево)
    }
  }

  function onPointerUp(ev) {
    activePointers.delete(ev.pointerId);
    // выход из щипка: пока пальцев <2 — щипок завершён; остаток НЕ превращаем в pan/tap (избегаем рывка)
    if (pinching) {
      if (activePointers.size < 2) { pinching = false; pinchDist0 = 0; }
      if (ev.pointerId === pointerId) { pointerId = null; dragging = false; }
      return;
    }
    if (ev.pointerId !== pointerId) return;
    if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
    if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
    try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
    const wasMoved = moved;
    const wasLong = longFired;
    pointerId = null;
    dragging = false;
    // касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
    if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);

    if (wasMoved || wasLong) {
      // после pan даём физике чуть устаканиться и уснуть
      if (wasMoved) wake();
      return;
    }
    // это был тап
    const tapNode = downNodeEl
      ? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId))
      : null;
    if (!tapNode) {
      // тап по пустому фону: двойной быстрый тап = сброс погружения/раскрытия (всплыть на весь граф)
      const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0;
      if (now && now - lastBgTapTs < DOUBLE_TAP_MS) { lastBgTapTs = 0; if (diveTargetId || spotActive) collapseAll(); }
      else lastBgTapTs = now;
      return;
    }
    if (tapNode.isFocus) {
      if (typeof onCenterTap === 'function') onCenterTap(tapNode);
      return;
    }
    if (typeof onNodeTap === 'function') {
      // запоминаем точку, из которой новый фокус влетит в центр; перестройку делает onNodeTap (setModel)
      pendingFocusOrigin = { id: String(tapNode.id), x: tapNode.x, y: tapNode.y };
      onNodeTap(tapNode);
    } else {
      // нет внешнего обработчика — внутреннее перецентрирование (фолбэк)
      startRecenterTween(tapNode.id);
    }
  }

  function onResize() {
    viewW = stage.clientWidth || window.innerWidth;
    viewH = stage.clientHeight || window.innerHeight;
    centerX = viewW / 2;
    centerY = viewH / 2;
    renderEdges();
  }

  // --- Жизненный цикл узлов (diffing) ----------------------------------------
  // Ghost-слой = СНИМОК старых АВАТАРОК (без линий!) на полноэкранном слое. Линии в шлейф НЕ
  // копируем намеренно: старые связи должны исчезать мгновенно вместе с перерисовкой графа, а не
  // висеть «ошмётками» секунду. Клон застывает на месте и лениво тает (scale 1→0.7 + opacity→0)
  // за 1000мс — мягкий породистый шлейф истории, — затем удаляется из DOM (строго через 1000мс).
  function spawnGhost() {
    if (!world.childElementCount) return;
    const ghost = document.createElement('div');
    ghost.className = 'fg-ghost-layer';
    const worldClone = world.cloneNode(true);      // .fg-world (центр) → только узлы на своих местах
    worldClone.style.transform = world.style.transform || '';
    ghost.append(worldClone);
    stage.insertBefore(ghost, edgesSvg);           // позади живых слоёв
    void ghost.offsetWidth;                        // рефлоу для запуска CSS-перехода
    ghost.style.transform = 'scale(0.7)';
    ghost.style.opacity = '0';
    window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс
  }

  // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM,
  // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками.
  function syncPositionsFromDOM() {
    const sr = stage.getBoundingClientRect();
    for (const n of nodes) {
      const dot = n.el.querySelector('.node-dot') || n.el;
      const r = dot.getBoundingClientRect();
      n.x = ((r.left + r.width / 2) - sr.left - centerX - camX) / zoom;
      n.y = ((r.top + r.height / 2) - sr.top - centerY - camY) / zoom;
      // живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой
      const o = parseFloat(getComputedStyle(n.el).opacity);
      if (Number.isFinite(o)) n.opacity = o;
    }
  }

  // Завершение CSS-bloom (по таймеру — гарантированно, даже при троттлинге rAF):
  // снимаем переходы, ставим узлы в финал и включаем лёгкую физику покачивания в покое.
  function endCssBloom() {
    cssBloomTimer = 0;
    if (!cssBloom) return;
    cssBloom = false;
    for (const n of nodes) {
      n.el.style.transition = '';
      const fo = (typeof n.bfo === 'number') ? n.bfo : 1; // финальная прозрачность (0 — скрыт фильтром)
      n.x = n.bfx; n.y = n.bfy;
      n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo;
      n.vx = 0; n.vy = 0; n.edgeGrow = 1;
    }
    layoutDeep(); // глубокие уровни ставим на орбиты родителей (их bfx — орбита центра, не годится)
    if (cssBloomKind === 'filter') {
      // ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся
      // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
      if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
      freezeGraph();
      return;
    }
    boost = 1;          // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80)
    renderNodes();
    renderEdges();
    wake();
  }

  // Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний:
  //  • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита);
  //  • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1);
  //  • исчезнувший — уходит призраком в глубину и удаляется.
  function setModel(nextModel) {
    const { focusId: newFocusId, specs } = computeSpecs(nextModel);
    const newIds = new Set(specs.map((s) => s.id));
    const oldById = new Map(nodes.map((n) => [String(n.id), n]));

    // старый узел нового фокуса (если был) — фокус глайдит из его позиции
    const focusOld = oldById.get(String(newFocusId));

    // снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
    spawnGhost();
    nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });

    focusId = String(newFocusId);
    if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; }

    const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`;
    let order = 0;
    let maxDelay = 0;
    const blooms = [];

    nodes = specs.map((spec) => {
      const oldNode = oldById.get(spec.id);
      let node;
      let isNew;
      if (oldNode && oldNode.dotOnly === spec.dotOnly) {
        updateNodeRole(oldNode, spec); // непрерывность: тот же DOM, новая роль/орбита
        node = oldNode; isNew = false;
      } else {
        if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
        node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
        isNew = true;
      }

      // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
      const finalX = node.isFocus ? 0 : node.tx;
      const finalY = node.isFocus ? 0 : node.ty;
      const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
      const finalOp = node.targetOpacity;  // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)

      // стартовая точка разлёта
      let fx; let fy; let fs; let fo; let delay = 0;
      if (node.isFocus) {
        if (focusOld) { fx = focusOld.x; fy = focusOld.y; fs = focusOld.scale; fo = 1; }            // глайд из старой позиции
        else if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) {
          fx = pendingFocusOrigin.x; fy = pendingFocusOrigin.y; fs = 0.6; fo = 1;                    // влёт из точки клика
        } else { fx = 0; fy = 0; fs = 0.3; fo = 0; }                                                // первичная инициализация
      } else if (isNew) {
        fx = Math.cos(node.angle) * 12; fy = Math.sin(node.angle) * 12; fs = 0.2; fo = 0;           // из центрального круга
        order += 1; delay = order * BLOOM_STAGGER;                                                  // каскад (волна)
      } else {
        fx = node.x; fy = node.y; fs = node.scale; fo = node.opacity;                               // непрерывность: с текущего места
      }

      // финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей)
      node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = finalOp;
      node.x = fx; node.y = fy;
      node.scale = finalScale; node.opacity = finalOp; node.targetScale = finalScale; node.targetOpacity = finalOp;
      node.hidden = false;
      // НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0);
      // переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия).
      node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1;

      // глубокие уровни (tier≥2) НЕ участвуют в CSS-bloom — их позиционирует layoutDeep/renderDeepNodes
      if (node.tier < 2) {
        maxDelay = Math.max(maxDelay, delay);
        blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay });
      }
      return node;
    });
    pendingFocusOrigin = null;
    diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
    rebuildIndex();      // обновляем nodeById/hasDeep под новый набор узлов
    updateBadges();      // бейджи-счётчики связей под новый набор
    updateA11y();        // текстовый список графа для скринридеров
    layoutDeep();        // сразу ставим глубокие уровни на орбиты вокруг родителей
    renderDeepNodes();   // и показываем их (CSS-bloom их элементы не трогает)
    emitDiveChange();    // сбрасываем хлебные крошки (новый граф = верхний уровень)

    camX = 0; camY = 0; applyWorldTransform();

    // ПАСС 1: стартовое состояние без перехода
    for (const b of blooms) { b.el.style.transition = 'none'; b.el.style.transform = b.start; b.el.style.opacity = String(b.fo); }
    void world.offsetWidth; // один форс-рефлоу, чтобы старт «зафиксировался»
    // ПАСС 2: включаем CSS-переход и ставим финал → компоновщик плавно «по маслу»
    // (работает даже когда JS-rAF троттлится; премиальная вязкая кривая Apple-уровня)
    for (const b of blooms) {
      b.el.style.transition = `transform ${BLOOM_MS}ms cubic-bezier(0.16, 1, 0.3, 1) ${b.delay}ms, opacity 700ms ease ${b.delay}ms`;
      b.el.style.transform = b.final;
      b.el.style.opacity = '1';
    }

    cssBloom = true;            // физика выключена; цикл лишь ведёт лучи за CSS-узлами
    cssBloomKind = 'bloom';     // после каскада — лёгкое упругое до-покачивание (boost)
    renderEdges();
    cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано
    wake();
  }

  stage.addEventListener('pointerdown', onPointerDown);
  stage.addEventListener('pointermove', onPointerMove);
  stage.addEventListener('pointerup', onPointerUp);
  stage.addEventListener('pointercancel', onPointerUp);
  stage.addEventListener('pointerover', onPointerOver);   // ховер мышью → превью ветки
  stage.addEventListener('pointerout', onPointerOut);
  stage.addEventListener('wheel', onWheel, { passive: false }); // зум колесом мыши
  window.addEventListener('resize', onResize);

  let ro = null;
  if (typeof ResizeObserver !== 'undefined') {
    ro = new ResizeObserver(() => onResize());
    ro.observe(stage);
  }

  setModel(model);

  return {
    recenter: (id) => startRecenterTween(id),
    setModel,
    setFilter,
    toggleExpand,   // mind-map: ЗАФИКСИРОВАТЬ/снять раскрытие ветки кликом (pinned)
    setHover,       // mind-map: ВРЕМЕННОЕ раскрытие ветки наведением (node | null)
    diveTo,         // Smart Zoom: погрузиться в узел 2-го+ уровня (наезд камеры, «аквариум»)
    exitDive,       // Smart Zoom: всплыть из погружения на уровень назад
    collapseAll,    // mind-map: свернуть всё + всплыть наверх (тап по корню)
    findNode,       // поиск узла по имени/логину → { id, name, tier } | null (для строки поиска)
    getDivePath: () => divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })), // хлебные крошки
    getFocusNode: () => nodes.find((n) => n.isFocus) || null,
    // --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) -------------------
    // Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера.
    debugState: () => ({
      zoom: +zoom.toFixed(3), camX: Math.round(camX), camY: Math.round(camY), diveTargetId, surfacing, spotActive, focusId,
      nodes: nodes.map((n) => ({ id: String(n.id), tier: n.tier, lod: n.lod, pinned: !!n.pinned, hovered: !!n.hovered, sibIndex: n.sibIndex, expandP: +(n.expandP || 0).toFixed(3), x: Math.round(n.x), y: Math.round(n.y), scale: +(n.scale || 0).toFixed(3), depthScale: +(n.depthScale || 1).toFixed(3), depthBlur: +(n.depthBlur || 0).toFixed(2), opacity: +(n.opacity || 0).toFixed(3), spotCur: +(n.spotCur || 1).toFixed(3) })),
    }),
    // Детерминированно докрутить анимацию до покоя (обходит троттлинг rAF в фоновых вкладках/тестах).
    pumpForTest: (maxFrames = 1200) => {
      let ts = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0;
      let i = 0;
      for (; i < maxFrames; i += 1) { ts += 16; tick(ts); if (!rafId) break; } // tick заморозился → rafId=0
      if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
      return i + 1;
    },
    destroy() {
      if (rafId) cancelAnimationFrame(rafId);
      rafId = 0;
      if (longTimer) window.clearTimeout(longTimer);
      stage.removeEventListener('pointerdown', onPointerDown);
      stage.removeEventListener('pointermove', onPointerMove);
      stage.removeEventListener('pointerup', onPointerUp);
      stage.removeEventListener('pointercancel', onPointerUp);
      stage.removeEventListener('pointerover', onPointerOver);
      stage.removeEventListener('pointerout', onPointerOut);
      stage.removeEventListener('wheel', onWheel);
      window.removeEventListener('resize', onResize);
      if (ro) ro.disconnect();
      edgesSvg.remove();
      world.remove();
      a11y.remove();
    },
  };
}

/**
 * Конвертирует данные формы ТЗ (focusUser + connections[]) в нейтральную модель движка.
 * Используется на этапе мок-прототипа (Фаза 1).
 */
export function buildModelFromTz(tz) {
  const focus = tz?.focusUser || {};
  const focusNode = {
    id: String(focus.id || 'focus'),
    login: focus.login || focus.id || '',
    name: focus.name || '',
    avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null,
    photo: focus.photo || null,
    relationType: 'self',
    strength: 1,
    shining: String(focus.status || '').toLowerCase() === 'shining',
    tier: 1,
  };
  const connections = Array.isArray(tz?.connections) ? tz.connections : [];
  const peerNodes = connections.map((c) => ({
    id: String(c.id),
    login: c.login || c.id || '',
    name: c.name || '',
    avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null,
    photo: c.photo || null,
    relationType: c.relationType || 'contact',
    strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5,
    shining: String(c.status || '').toLowerCase() === 'shining',
    tier: c.hasOwnConnections === false ? 1 : (c.tier || 1),
  }));
  return { focusId: focusNode.id, nodes: [focusNode, ...peerNodes] };
}
