SHiNE-server/shine-UI/js/pages/network/force-graph.js
AidarKC 41edd1423c Связи: двухслойные линии-световоды, живой фон и стеклянные фильтры
- Сияющие связи — двухслойный неоновый «световод»: размытый glow (4px, blur 2px,
  opacity 0.4) + тонкий чёткий core (1.5px, #e0f7fc). Объёмное OLED-свечение,
  линия остаётся изящной. Оба слоя растут синхронно (общий dashoffset).
- Обычные линии — тоньше (1.0–1.2px) и глубокий уход в прозрачность (0.42 → 0.07),
  чтобы матовые связи не спорили с сияющими.
- Живой фон-«небула»: глубокое размытое сине-голубое облако под центром, медленная
  пульсация радиуса/яркости + переливы индиго↔ультрамарин (hue-rotate, 7с).
- Стеклянные чипы фильтров (frosted glass): rgba(255,255,255,0.03) + backdrop blur(12px)
  + граница 0.5px solid rgba(255,255,255,0.1); активный подсвечен сине-голубым.
- Бамп client.version → 1.2.138; документация фичи обновлена.

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

1105 lines
56 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 EDGE_LERP = 0.25; // догон концов линии за узлом за кадр (эффект натянутой резинки)
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.481.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-подобные точки
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;
}
// Решатель кубической кривой Безье (CSS cubic-bezier): прогресс x → значение y.
function cubicBezier(x1, y1, x2, y2) {
const cx = 3 * x1;
const bx = 3 * (x2 - x1) - cx;
const ax = 1 - cx - bx;
const cy = 3 * y1;
const by = 3 * (y2 - y1) - cy;
const ay = 1 - cy - by;
const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
const sampleY = (t) => ((ay * t + by) * t + cy) * t;
const dX = (t) => (3 * ax * t + 2 * bx) * t + cx;
return (x) => {
let t = x;
for (let i = 0; i < 6; i += 1) {
const d = dX(t);
if (Math.abs(d) < 1e-6) break;
t -= (sampleX(t) - x) / d;
}
return sampleY(Math.max(0, Math.min(1, t)));
};
}
// Премиальная «вязкая» кривая для разлёта узлов (быстрый старт → очень мягкая посадка).
const EASE_BLOOM = cubicBezier(0.16, 1, 0.3, 1);
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.innerHTML = '<defs><filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" '
+ 'color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></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 } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
const world = document.createElement('div');
world.className = 'fg-world';
// «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется,
// когда под него попадает узел (визуальная зона фокуса при свободном панорамировании).
const reticle = document.createElement('div');
reticle.className = 'fg-reticle';
stage.append(edgesSvg, world, reticle);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование)
let camX = 0;
let camY = 0;
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 = '';
// Управление циклом 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;
// --- Построение модели -----------------------------------------------------
// Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга:
// фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки.
function computeSpecs(srcModel) {
const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : [];
const fId = String(srcModel?.focusId || (list[0] && list[0].id) || '');
const peers = list
.filter((n) => String(n.id) !== fId)
.sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0));
const dotCount = Math.max(0, peers.length - MAX_FULL_NODES);
if (dotCount > 0) {
console.info(`[force-graph] связей ${peers.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 });
peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES }));
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;
const scale = isFocus ? FOCUS_SCALE : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE);
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
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,
dotOnly,
strength,
targetR,
angle,
tx,
ty,
x: tx * INTRO_FACTOR,
y: ty * INTRO_FACTOR,
lerpX: tx * INTRO_FACTOR,
lerpY: ty * INTRO_FACTOR,
vx: 0,
vy: 0,
scale,
targetScale: scale,
hidden: false,
opacity: 1,
targetOpacity: 1,
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 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',
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id);
el.title = src.name || src.login || '';
return el;
}
el.className = [
'fg-node',
isFocus ? 'is-focus' : '',
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
tier >= 2 ? 'is-secondary' : '',
].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);
const label = document.createElement('span');
label.className = 'fg-node-label';
label.textContent = src.name || src.login || '';
el.append(label);
return el;
}
// Переиспользование узла при диффинге: сохраняем 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.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 : (spec.dotOnly ? 1 : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
node.targetOpacity = 1;
node.hidden = false;
node.bloom = false;
node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26));
// обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM
node.el.className = spec.dotOnly
? ['fg-node', 'fg-dot', 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-secondary' : ''].filter(Boolean).join(' ');
}
// --- Рендер ----------------------------------------------------------------
function applyWorldTransform() {
world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`;
}
function renderNodes() {
for (const n of nodes) {
n.el.style.transform =
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`;
n.el.style.opacity = String(n.opacity);
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
}
}
function renderEdges() {
const focus = nodes.find((n) => n.id === focusId);
if (!focus) {
edgesSvg.innerHTML = '';
return;
}
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость
const tx = (n) => centerX + camX + n.x;
const ty = (n) => centerY + camY + n.y;
const fx = tx(focus);
const fy = ty(focus);
const fr = focus.dotRadius * focus.scale + 4;
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;
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) : nx;
const ey = growing ? (centerY + camY + n.bfy) : 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 + 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;
// желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости
const desX = mx + (-uy) * baseBow + invX * lag;
const desY = my + ux * baseBow + invY * lag;
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 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)}"`;
}
if (shine) {
// glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr)
const gOp = (0.4 * nodeOpacity).toFixed(2);
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`);
} else {
// обычная: тонкая дуга, градиент 0.42 (центр) → 0.07 (аватарка) — глубокий уход в прозрачность,
// чтобы матовые связи не спорили с сияющими.
const gid = `fg-grad-${gi}`;
gi += 1;
defs.push(
`<linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}">`
+ `<stop offset="0" stop-color="${FOCUS_NEON}" stop-opacity="0.42"/>`
+ `<stop offset="1" stop-color="${relationColor(n.relationType)}" stop-opacity="0.07"/></linearGradient>`
);
const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.01.2px
const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
parts.push(`<path d="${d}" fill="none" stroke="url(#${gid})" stroke-width="${sw}" stroke-linecap="round"${op}${dashAttr} />`);
}
}
edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
}
function updateReticle() {
// ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0)
let best = Infinity;
for (const n of nodes) {
if (n.hidden) continue;
const d = Math.hypot(camX + n.x, camY + n.y);
if (d < best) best = d;
}
reticle.classList.toggle('is-locked', best < 46);
}
function renderAll() {
renderNodes();
renderEdges();
updateReticle();
}
// --- Физика (пружины + отталкивание) ---------------------------------------
// Фокус не «пинится» жёстко, а влетает к центру пружиной (упругая стабилизация).
// Периферия держится радиальной пружиной на орбите и расталкивается силой charge —
// получается органичная плавающая структура, а не жёсткий круг.
function stepPhysics() {
let totalV = 0;
for (const n of nodes) {
if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает
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;
// отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре)
for (const m of nodes) {
if (m === n || m.hidden) 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);
const f = chargeNow / 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;
}
// Концы линий догоняют узлы с запаздыванием (эффект резинки): lerp-позиция тянется за реальной.
function advanceLerp() {
for (const n of nodes) {
n.lerpX += (n.x - n.lerpX) * EDGE_LERP;
n.lerpY += (n.y - n.lerpY) * EDGE_LERP;
}
}
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»).
function advanceVisual() {
for (const n of nodes) {
n.scale += (n.targetScale - n.scale) * 0.2;
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
// линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
}
}
// Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки).
function visualSettling() {
for (const n of nodes) {
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || 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;
n.lerpX = n.x; n.lerpY = n.y;
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();
}
// Жёсткая заморозка: гасим скорости, округляем координаты до целых пикселей,
// подтягиваем lerp и НЕ перезапускаем цикл — граф замирает намертво (без «треска»).
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.lerpX = n.x;
n.lerpY = n.y;
n.scale = n.targetScale;
n.opacity = n.targetOpacity;
}
renderAll(); // финальный кадр на целых координатах
}
// --- Цикл с kill-switch + инерция + заморозка ------------------------------
function tick(ts) {
rafId = 0;
// режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики)
if (cssBloom) {
syncPositionsFromDOM();
renderEdges();
updateReticle();
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 {
panVelX = 0;
panVelY = 0;
}
// динамическая вязкость: первые ~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
}
advanceLerp();
renderAll();
const lerpSettling = nodes.some((n) => Math.abs(n.x - n.lerpX) + Math.abs(n.y - n.lerpY) > 0.5);
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || lerpSettling || 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;
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) {
if (pointerId !== null) return;
pointerId = ev.pointerId;
panVelX = 0; // новое касание мгновенно прерывает инерцию
panVelY = 0;
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;
const downNode = nodeFromEvent(ev);
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 onPointerMove(ev) {
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; }
cancelTween(); // жест прерывает анимацию центрирования
dragging = true;
}
if (moved) {
const newCamX = camStartX + dx;
const newCamY = camStartY + dy;
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
panVelY = newCamY - camY;
camX = newCamX;
camY = newCamY;
applyWorldTransform();
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
updateReticle();
}
}
function onPointerUp(ev) {
if (ev.pointerId !== pointerId) return;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
const wasMoved = moved;
const wasLong = longFired;
pointerId = null;
dragging = 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) 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мс
}
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
function pulseReticle() {
reticle.classList.remove('is-pulse');
void reticle.offsetWidth;
reticle.classList.add('is-pulse');
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
}
// Во время 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;
n.y = (r.top + r.height / 2) - sr.top - centerY - camY;
n.lerpX = n.x; n.lerpY = n.y;
// живая прозрачность из 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.lerpX = n.x; n.lerpY = n.y;
n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo;
n.vx = 0; n.vy = 0; n.edgeGrow = 1;
}
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.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
// стартовая точка разлёта
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 = 1;
node.x = fx; node.y = fy; node.lerpX = fx; node.lerpY = fy;
node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1;
node.hidden = false;
// НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0);
// переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия).
node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1;
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;
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();
pulseReticle();
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);
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,
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
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);
window.removeEventListener('resize', onResize);
if (ro) ro.disconnect();
edgesSvg.remove();
world.remove();
reticle.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] };
}