SHA256
Связи (эксперимент 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user