Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами. Движок (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>
901 lines
42 KiB
JavaScript
901 lines
42 KiB
JavaScript
// Движок интерактивной карты связей (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.48–1.52x)
|
||
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
||
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
||
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
|
||
const LONGPRESS_MS = 480; // порог долгого нажатия
|
||
const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки
|
||
|
||
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);
|
||
// изгиб строго перпендикулярный: заметная постоянная дуга (≈7–22px) +
|
||
// динамика от скорости узла → при движении нить выгибается, как натянутая резина
|
||
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.3–1.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] };
|
||
}
|