SHiNE-server/shine-UI/js/pages/network/force-graph.js
AidarKC f56e531384 Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами.

Движок (js/pages/network/force-graph.js):
- diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте);
- мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса;
- динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски;
- жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи;
- линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией;
- хард-лимит DOM-аватарок (остальное — SVG-точки).

Интеграция и UX:
- adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only);
- фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль;
- прицел в центре, дыхание фокуса, свечение сияющих;
- лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда.

Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md.
Бамп client.version 1.2.135 -> 1.2.136.

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

901 lines
42 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 } from '../../components/avatar-image.js';
// --- Параметры физики и анимации ---------------------------------------------
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.82; // базовое затухание скорости (свободное покачивание)
const FRICTION_BOOST = 0.92; // максимальная вязкость в первые ~600мс после перестроения (гасит «взрыв»)
const BOOST_FRAMES = 36; // длительность затухающего boost'а вязкости (~600мс @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 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)',
};
function easeOutCubic(t) {
const x = 1 - t;
return 1 - x * x * x;
}
function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
}
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом 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('http://www.w3.org/2000/svg', '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);
// Состояние камеры (панорамирование)
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;
// Прогресс «прорастания» линий 0→1 (1 = полностью вычерчены)
let edgeGrowth = 1;
// Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра.
let boost = 0;
let frictionNow = FRICTION;
let chargeNow = CHARGE;
// Инерция панорамирования (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,
el,
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
};
}
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);
const avatar = 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 = [];
for (const n of nodes) {
if (n === focus) continue;
if (n.hidden) 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;
const dx = nx - fx;
const dy = ny - 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 = nx - ux * nr;
const y2 = ny - uy * nr;
// контрольная точка кривой Безье: постоянный лёгкий изгиб (провисание) перпендикулярно
// линии + динамика от запаздывания (при движении узла нить выгибается сильнее)
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const segLen0 = Math.hypot(x2 - x1, y2 - y1);
// изгиб строго перпендикулярный: заметная постоянная дуга (≈722px) +
// динамика от скорости узла → при движении нить выгибается, как натянутая резина
const speed = Math.hypot(n.vx, n.vy);
const bow = Math.max(7, Math.min(22, segLen0 * 0.13)) + Math.min(16, speed * 1.2);
const cpx = mx + (-uy) * bow * 2; // CP даёт середину Q-кривой = M + perp*bow
const cpy = my + ux * bow * 2;
// минимализм: тонкие (1.31.8px), полупрозрачные линии — без «энергетических лучей»
const w = 1.3 + n.strength * 0.5;
// прорастание: длину пути приближаем хордой, dash-offset → 0
let dash = '';
if (edgeGrowth < 1) {
const segLen = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy)) || 1;
dash = ` stroke-dasharray="${segLen.toFixed(1)}" stroke-dashoffset="${(segLen * (1 - edgeGrowth)).toFixed(1)}"`;
}
parts.push(
`<path d="M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}" `
+ `fill="none" stroke="${relationColor(n.relationType)}" stroke-opacity="0.42" stroke-width="${w.toFixed(2)}" stroke-linecap="round"${dash} />`
);
}
edgesSvg.innerHTML = 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;
}
}
// Плавное приближение масштаба/прозрачности к целям (bloom новых, рост/уменьшение при смене роли).
function advanceVisual() {
for (const n of nodes) {
n.scale += (n.targetScale - n.scale) * 0.2;
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
}
}
// Не «успокоились» ли ещё визуальные параметры (для условия заморозки).
function visualSettling() {
for (const n of nodes) {
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01) 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();
}
function stepTween(ts) {
if (!tween.startTs) tween.startTs = ts;
const raw = Math.min(1, (ts - tween.startTs) / TWEEN_MS);
const t = easeOutCubic(raw);
for (const n of nodes) {
const a = tween.from.get(n.id);
const b = tween.to.get(n.id);
if (!a || !b) continue;
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;
}
camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * t;
camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * t;
applyWorldTransform();
if (raw >= 1) {
tween = null; // твин завершён
// синхронизируем цели визуала с текущими, чтобы advanceVisual не «откатил» (важно для фильтра)
for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; }
}
}
// Прерывание твина жестом (требование «конфликт жестов»): фиксируем текущие позиции и отдаём пальцу.
function cancelTween() {
if (!tween) return;
tween = null;
for (const n of nodes) { n.vx = 0; n.vy = 0; }
}
// Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
// Видимые перераспределяются по орбите, скрытые плавно гаснут (scale↓ + opacity→0).
function setFilter(predicate) {
const pred = typeof predicate === 'function' ? predicate : () => true;
const from = new Map();
const to = new Map();
nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale, opacity: n.opacity }));
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 focus = nodes.find((n) => n.isFocus);
if (focus) to.set(focus.id, { x: 0, y: 0, scale: FOCUS_SCALE, opacity: 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;
to.set(n.id, { x: n.tx, y: n.ty, scale: sc, opacity: 1 });
});
nodes.forEach((n) => {
if (n.isFocus || !n.hidden) return;
// скрытые: подтягиваем к центру и гасим
to.set(n.id, { x: n.x * 0.35, y: n.y * 0.35, scale: 0.2, opacity: 0 });
});
// фильтр не двигает камеру (в отличие от центрирования)
tween = { startTs: 0, from, to, camFrom: { x: camX, y: camY }, camTo: { x: camX, y: camY } };
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;
// инерция панорамирования (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;
}
if (edgeGrowth < 1) edgeGrowth = Math.min(1, edgeGrowth + 0.07); // прорастание линий ~15 кадров
// динамическая вязкость: первые ~400мс после перестроения трение выше (0.90→0.82),
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту мягко
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 || edgeGrowth < 1 || 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-слой = СНИМОК всего старого графа (узлы + линии) на полноэкранном слое.
// Клон застывает СТРОГО НА МЕСТЕ (полноэкранный overlay → координаты не сбрасываются),
// плавно уменьшается (scale 1→0.7) и растворяется (opacity 0.4→0) за 500мс — красивый
// шлейф истории перехода, — после чего полностью удаляется из DOM.
function spawnGhost() {
if (!world.childElementCount) return;
const ghost = document.createElement('div');
ghost.className = 'fg-ghost-layer';
const edgesClone = edgesSvg.cloneNode(true); // .fg-edges (inset:0) → линии совпадают по координатам
edgesClone.style.opacity = ''; // снимаем возможный inline-fade, слой задаёт прозрачность сам
const worldClone = world.cloneNode(true); // .fg-world (центр) → узлы на своих местах
worldClone.style.transform = world.style.transform || '';
ghost.append(edgesClone, worldClone);
stage.insertBefore(ghost, edgesSvg); // позади живых слоёв
void ghost.offsetWidth; // рефлоу для запуска CSS-перехода
ghost.style.transform = 'scale(0.7)';
ghost.style.opacity = '0';
window.setTimeout(() => ghost.remove(), 800);
}
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
function pulseReticle() {
reticle.classList.remove('is-pulse');
void reticle.offsetWidth;
reticle.classList.add('is-pulse');
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
}
// Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний:
// • общий узел (тот же 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));
const originX = focusOld ? focusOld.x : (pendingFocusOrigin ? pendingFocusOrigin.x : 0);
const originY = focusOld ? focusOld.y : (pendingFocusOrigin ? pendingFocusOrigin.y : 0);
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
spawnGhost();
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
focusId = String(newFocusId);
edgeGrowth = 0; // линии к новым узлам прорастают из центра
boost = 1; // включаем повышенную вязкость на ~400мс (гасим энергию разлёта)
const fresh = [];
let bloomOrder = 0;
nodes = specs.map((spec) => {
const old = oldById.get(spec.id);
if (old && old.dotOnly === spec.dotOnly) {
updateNodeRole(old, spec); // непрерывность: тот же DOM, новая цель → перелёт пружиной
return old;
}
if (old) old.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
const node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
// периферия «выстреливает» из центрального круга (0,0); смещение вдоль угла даёт направление силам
const bx = Math.cos(node.angle) * 14;
const by = Math.sin(node.angle) * 14;
node.x = node.isFocus ? originX : bx;
node.y = node.isFocus ? originY : by;
node.lerpX = node.x; node.lerpY = node.y;
node.scale = 0.01; node.opacity = 0; node.bloom = true;
node.bloomScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (Number(spec.src.tier) >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
if (node.isFocus) {
node.targetScale = node.bloomScale; node.targetOpacity = 1; // фокус виден сразу (влетает)
} else {
// периферия: держим скрытой в центре и «выстреливаем» по очереди (каскад 40мс)
node.hidden = true;
node.targetScale = 0; node.targetOpacity = 0;
node.bloomOrder = bloomOrder++;
fresh.push(node);
}
return node;
});
// новый фокус «влетает» из точки клика (если кликнули по периферийному узлу)
if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) {
const f = nodes.find((n) => n.isFocus);
if (f && !focusOld) { f.x = pendingFocusOrigin.x; f.y = pendingFocusOrigin.y; f.lerpX = f.x; f.lerpY = f.y; }
}
pendingFocusOrigin = null;
// каскад: каждый новый узел освобождается из центра через order*40мс → волна
fresh.forEach((node) => {
window.setTimeout(() => {
node.hidden = false;
node.targetScale = node.bloomScale;
node.targetOpacity = 1;
wake();
}, node.bloomOrder * 40);
});
camX = 0;
camY = 0;
applyWorldTransform();
renderAll();
// линии: плавно проявляем (старые ушли с призраком)
edgesSvg.style.opacity = '0';
void edgesSvg.offsetWidth;
edgesSvg.style.opacity = '1';
pulseReticle();
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,
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,
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] };
}