SHiNE-server/shine-UI/js/pages/network/force-graph.js
Pixel e3bebff618 anim (13.06): ускорение разлёта узлов — BLOOM_MS 900→550
Дети «выстреливают» из центра почти вдвое быстрее; easing и каскад (stagger)
прежние. Убирает ощущение тяжести/«подтупливания» при смене центра.
Версия 1.2.162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:36:46 +03:00

1748 lines
112 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Движок интерактивной карты связей (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 = 550; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
const FOCUS_SCALE = 1.78; // базовый масштаб фокуса — центр крупнее (иерархия, рычаг 2; ±дыхание)
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)';
// Радиус видимой сферы орба (world-единицы), синхронно с CSS `.fg-node .node-dot` = 58px → радиус 29
// (сфера орба ≈ радиусу node-dot). ЕДИНЫЙ источник радиуса для контакта линий с кромкой и раскладки детей —
// меняешь размер орба → меняй здесь и в CSS вместе, линии останутся впритык.
const ORB_R = 29;
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;
}
// SVG-«стеклянный орб» для аватара (фото в стеклянной сфере ≈90% + блик + rim + свечение).
// Уникальные id на экземпляр (иначе defs конфликтуют). Тело стекла тёмно-синее/прозрачное (без серости),
// вторичный блик убран (был «звёздочкой»). Фолбэк: если фото не загрузилось — остаются инициалы.
let orbSeq = 0;
function buildGlassOrb(src, opts) {
const o = opts || {};
const u = 'o' + (orbSeq += 1);
const glowOp = o.isFocus ? 0.34 : 0.28;
const glowSpread = o.isFocus ? 7 : 4.5; // центр — шире/мягче ореол (рычаг 1); спутники без изменений
const imgFilter = o.isFocus ? 'grayscale(0.9) contrast(1.04)' : 'saturate(0.85) brightness(0.97)';
const init = String(o.initials || '').slice(0, 2);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 100 100');
svg.setAttribute('class', 'fg-orb-svg');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = ''
+ '<defs>'
+ '<clipPath id="c' + u + '"><circle cx="50" cy="50" r="38"/></clipPath>'
+ '<radialGradient id="sh' + u + '" cx="50%" cy="45%" r="52%"><stop offset="62%" stop-color="#bfe3ff" stop-opacity="0"/><stop offset="90%" stop-color="#dff1ff" stop-opacity="0.5"/><stop offset="100%" stop-color="#5f93b8" stop-opacity="0.18"/></radialGradient>'
+ '<radialGradient id="sd' + u + '" cx="50%" cy="60%" r="58%"><stop offset="72%" stop-color="#000000" stop-opacity="0"/><stop offset="100%" stop-color="#02060d" stop-opacity="0.22"/></radialGradient>'
+ '<linearGradient id="sp' + u + '" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#ffffff" stop-opacity="0.5"/><stop offset="100%" stop-color="#ffffff" stop-opacity="0"/></linearGradient>'
+ '<filter id="so' + u + '" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="1.3"/></filter>'
+ '<filter id="sf' + u + '" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="3.2"/></filter>'
+ '<filter id="gl' + u + '" x="-70%" y="-70%" width="240%" height="240%"><feGaussianBlur stdDeviation="' + glowSpread + '"/></filter>'
+ '</defs>'
+ '<circle cx="50" cy="50" r="42" fill="#5facd4" opacity="' + glowOp + '" filter="url(#gl' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="#0a1626" opacity="0.3"/>'
+ '<circle cx="50" cy="50" r="38" fill="#26344a"/>'
+ (init ? '<text x="50" y="58" text-anchor="middle" fill="#cfe0ff" font-family="sans-serif" font-weight="600" font-size="22">' + init + '</text>' : '')
+ (src ? '<image href="' + src + '" x="12" y="12" width="76" height="76" clip-path="url(#c' + u + ')" preserveAspectRatio="xMidYMid slice" style="filter:' + imgFilter + '"/>' : '')
+ '<circle cx="50" cy="50" r="38" fill="url(#sd' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="url(#sh' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="#2f7fd0" opacity="0.07"/>'
+ '<path d="M74 16 A42 42 0 0 1 90 61" fill="none" stroke="#9fd2f2" stroke-width="1.6" stroke-opacity="0.55" stroke-linecap="round" filter="url(#so' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="none" stroke="#a9d6f0" stroke-width="0.9" stroke-opacity="0.2" filter="url(#so' + u + ')"/>'
+ '<ellipse cx="40" cy="31" rx="24" ry="12" fill="url(#sp' + u + ')" transform="rotate(-28 40 31)" filter="url(#sf' + u + ')"/>';
const im = svg.querySelector('image');
if (im) im.addEventListener('error', () => { try { im.remove(); } catch (e) { /* останутся инициалы */ } });
return svg;
}
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);
// Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
// синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
const photoSrc = src.photo || (src.avatar && src.avatar !== 'url_to_image' ? src.avatar : null);
const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
const dot = document.createElement('div');
dot.className = 'avatar node-dot fg-orb-host';
dot.appendChild(buildGlassOrb(photoSrc, { isFocus, initials }));
el.append(dot);
// Бейдж-счётчик числа связей (заполняется в 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 = ORB_R * (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;
// радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…),
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) — легаси, давало разный зазор.
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
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.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z;
// концы линии — ровно на кромке сферы орба (радиус ORB_R для полных орбов), без зазора и без захода внутрь
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.01.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) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
}
}
} else if (n.tier === 2) {
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
}
}
} else if (shine) {
// СИЯЮЩАЯ связь → цвет сияющей линии (плазма). Только сияющим — несияющие (в т.ч. активный путь
// погружения track/onPath) идут ниже в ЦВЕТ КАТЕГОРИИ. Плазма: ОДИН S-путь + ТРИ слоя на одном d.
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] };
}