SHiNE-server/shine-UI/js/pages/network/force-graph.js
Pixel 345a21a211 Связи (эксперимент pixel-web): база — микро-взаимодействия + глубина
Фиксирую накопленные черновики как точку отката перед режимом «Интерактивная паутина»:
- press/pan-bend (резиновые нити при свайпе, тактильное «вдавливание» узла);
- глубина 2-3 уровней (прототип «Вселенная», переключатель в лаборатории);
- прогрессивное раскрытие (глубина скрыта, выплывает по нажатию/наведению).

Ветка pixel-web — экспериментальная (отдельно от pixel-08.06/PR), чтобы можно было откатиться.
Бамп client.version → 1.2.142.

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

1225 lines
68 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 = 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-подобные точки
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше
const DEEP2_OPACITY = 0.4; // и полупрозрачный
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
const RELATION_COLORS = {
family: 'rgba(255, 159, 94, 0.92)',
friend: 'rgba(120, 179, 255, 0.9)',
business: 'rgba(190, 150, 255, 0.9)',
contact: 'rgba(170, 190, 220, 0.7)',
};
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
function easeOutCubic(t) {
const x = 1 - t;
return 1 - x * x * x;
}
function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
}
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
function ensureShineFilter() {
if (typeof document === 'undefined' || document.getElementById('fg-shine-glow')) return;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.style.position = 'absolute';
svg.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 = '';
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); };
// Управление циклом 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;
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), // «трек прохождения» — линия к этому узлу горит ярко
expandTarget: 0, // 0/1 — раскрыты ли дочерние глубокие узлы (по клику/ховеру)
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,
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))),
};
}
// Аватар из прямого URL-фото (тестовые данные лаборатории). Структура — как у renderUserAvatar
// (переиспользуем CSS .avatar/.node-dot), фолбэк на инициалы при ошибке загрузки (офлайн).
function buildPhotoAvatar(src) {
const wrap = document.createElement('div');
wrap.className = 'avatar avatar-image node-dot';
const fb = document.createElement('span');
fb.className = 'avatar-fallback';
fb.textContent = buildAvatarInitials({ login: src.login || String(src.id), firstName: src.name || '' });
wrap.append(fb);
const img = document.createElement('img');
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.onload = () => wrap.classList.add('has-image');
img.onerror = () => { img.remove(); }; // нет сети — остаются инициалы
img.src = src.photo;
wrap.append(img);
return wrap;
}
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
const el = document.createElement('button');
el.type = 'button';
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
if (dotOnly) {
el.className = [
'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся точка)
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id);
el.title = src.name || src.login || '';
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' : '',
].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.parentId = String(src.parentId || '');
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
node.track = Boolean(src.track);
node.expandTarget = 0; node.expandP = 0; // при перестроении глубина схлопывается (раскрытие — по клику)
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' : ''].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' : '';
}
}
// Глубина 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 r = (tier === 2 ? DEEP_R2 : DEEP_R3) * e; // при e=0 — в центре родителя, при 1 — на орбите
n.x = p.x + Math.cos(n.deepAngle) * r;
n.y = p.y + Math.sin(n.deepAngle) * r;
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
const baseSc = tier === 2 ? DEEP2_SCALE : 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, пока что-то едет.
function advanceExpand() {
let moving = false;
for (const n of nodes) {
const t = n.expandTarget || 0;
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;
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);
}
}
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 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;
const fy = centerY + camY + parent.y;
const fr = parent.dotRadius * parent.scale + 4;
const nx = tx(n);
const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
const ex = growing ? (centerX + camX + n.bfx) : 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;
// ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца,
// сильнее у дальних узлов (инерция периферии). 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 d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
// ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
// (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
let dashAttr = '';
if (growing) {
const finalD = Math.hypot(n.bfx, n.bfy) || 1;
const curD = Math.hypot(n.x, n.y);
const growP = Math.max(0, Math.min(1, curD / finalD));
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
}
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) {
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe).toFixed(2)}" />`);
} else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe).toFixed(2)}" />`);
} else if (shine || n.track) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
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; } // скрытые фильтром узлы физика не двигает
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);
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;
}
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»).
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;
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();
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 if (dragging) {
// палец удерживается без движения → скорость (и изгиб нитей) мягко расслабляется,
// но onPointerMove перезапишет её свежим дельтой при следующем движении
panVelX *= 0.7;
panVelY *= 0.7;
} else {
panVelX = 0;
panVelY = 0;
}
advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю
// динамическая вязкость: первые ~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 || 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;
let pressedNode = null; // узел, чьи глубокие дети сейчас «выплыли» (по нажатию)
let hoverNode = null; // узел под курсором (десктоп) — тоже раскрывает свои глубокие связи
// раскрыть/схлопнуть глубокие уровни вокруг узла (локальный bloom по взаимодействию)
function expandNode(n) { if (n && !n.expandTarget) { n.expandTarget = 1; wake(); } }
function collapseNode(n) { if (n && n.expandTarget) { n.expandTarget = 0; wake(); } }
// Hover (десктоп): наведение раскрывает глубокие связи узла, увод/смена — схлопывает.
function onHoverMove(ev) {
if (pointerId !== null || dragging) return; // только когда не нажато и не тащим
const n = nodeFromEvent(ev);
if (n === hoverNode) return;
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
hoverNode = n;
if (n) expandNode(n);
}
function onHoverLeave() {
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
hoverNode = null;
}
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;
if (downNodeEl) downNodeEl.classList.add('is-pressed'); // тактильный отклик «нажатия вглубь»
const downNode = nodeFromEvent(ev);
if (downNode) { pressedNode = downNode; expandNode(downNode); } // локальный bloom его глубоких связей
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; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
if (pressedNode) { collapseNode(pressedNode); pressedNode = 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(); // рёбра следуют за камерой синхронно (дёшево)
updateReticle();
}
}
function onPointerUp(ev) {
if (ev.pointerId !== pointerId) return;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // отпустили — глубина уходит обратно
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;
// живая прозрачность из 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;
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей
renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает)
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('pointermove', onHoverMove); // hover-раскрытие глубины (десктоп)
stage.addEventListener('pointerleave', onHoverLeave);
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('pointermove', onHoverMove);
stage.removeEventListener('pointerleave', onHoverLeave);
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] };
}