Связи (эксперимент 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>
This commit is contained in:
Pixel
2026-06-09 21:51:48 +03:00
co-authored by Claude Opus 4.8
parent 3de992d251
commit 345a21a211
4 changed files with 365 additions and 51 deletions
+194 -33
View File
@@ -42,6 +42,14 @@ const PAN_THRESHOLD = 8; // порог смещения (px), после
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)',
@@ -129,6 +137,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Узлы движка: { 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;
@@ -155,23 +166,37 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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) || '');
const peers = list
.filter((n) => String(n.id) !== fId)
// 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 dotCount = Math.max(0, peers.length - MAX_FULL_NODES);
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] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`);
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 });
peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES }));
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 };
}
@@ -189,7 +214,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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);
// масштаб/прозрачность по уровню глубины: 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;
@@ -200,6 +227,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
...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,
@@ -213,12 +245,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
scale,
targetScale: scale,
hidden: false,
opacity: 1,
targetOpacity: 1,
opacity: tier >= 2 ? op : 1,
targetOpacity: op,
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))),
};
}
@@ -249,6 +281,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (dotOnly) {
el.className = [
'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся точка)
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
].filter(Boolean).join(' ');
@@ -261,6 +294,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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);
@@ -291,6 +325,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -301,15 +339,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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.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 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26));
// обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM
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', 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(' ');
? ['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(' ');
}
// --- Рендер ----------------------------------------------------------------
@@ -326,6 +364,57 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
}
// Глубина 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) {
@@ -336,10 +425,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const tx = (n) => centerX + camX + n.x;
const ty = (n) => centerY + camY + n.y;
const fx = tx(focus);
const fy = ty(focus);
const fr = focus.dotRadius * focus.scale + 4;
const focusLogin = String(focus.login || '').toLowerCase();
const parts = [];
const defs = [];
@@ -350,6 +435,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -382,9 +472,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости
const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения
const invY = speed > 0.01 ? -n.vy / speed : 0;
// желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости
const desX = mx + (-uy) * baseBow + invX * lag;
const desY = my + ux * baseBow + invY * lag;
// ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца,
// сильнее у дальних узлов (инерция периферии). 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;
// Связь рисуем по статусу узла:
@@ -403,8 +500,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
}
if (shine) {
// glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr)
const 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}" />`);
@@ -452,6 +557,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -467,9 +573,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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) continue;
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;
@@ -667,6 +774,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
n.scale = n.targetScale;
n.opacity = n.targetOpacity;
}
panBendX = 0; panBendY = 0; // нити в покое — ровные базовые дуги
layoutDeep(); // глубокие уровни — на орбитах родителей перед финальным кадром
renderAll(); // финальный кадр на целых координатах
}
@@ -674,9 +783,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function tick(ts) {
rafId = 0;
// режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики)
// режим CSS-bloom: узлы 1-го уровня анимирует компоновщик — мы лишь ведём лучи за ними (без физики);
// глубокие уровни (tier≥2) не в CSS-bloom — позиционируем их из JS, чтобы следовали за родителями
if (cssBloom) {
syncPositionsFromDOM();
advanceExpand();
layoutDeep();
renderDeepNodes();
renderEdges();
updateReticle();
schedule();
@@ -691,10 +804,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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),
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
@@ -710,9 +829,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity
}
const expanding = advanceExpand(); // раскрытие/схлопывание глубоких уровней (по клику/ховеру)
layoutDeep(); // глубокие уровни следуют за родителями (после шага физики 1-го уровня)
renderAll();
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || visualSettling()) {
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(); // система успокоилась — замираем
@@ -737,6 +859,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -758,7 +900,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -777,6 +921,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
}
@@ -788,6 +934,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
camX = newCamX;
camY = newCamY;
applyWorldTransform();
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
updateReticle();
}
@@ -796,6 +943,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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;
@@ -889,6 +1038,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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.
@@ -942,7 +1092,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
const finalX = node.isFocus ? 0 : node.tx;
const finalY = node.isFocus ? 0 : node.ty;
const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
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;
@@ -959,19 +1110,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей)
node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1;
node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = finalOp;
node.x = fx; node.y = fy;
node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1;
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;
maxDelay = Math.max(maxDelay, delay);
blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay });
// глубокие уровни (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();
@@ -996,6 +1153,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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);
@@ -1019,6 +1178,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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);