Связи (эксперимент 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
parent 3de992d251
commit 345a21a211
4 changed files with 365 additions and 51 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.141
client.version=1.2.142
server.version=1.2.127

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);

View File

@ -4,6 +4,10 @@
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально).
//
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
// Это чисто визуальный лабораторный эксперимент на мок-данных.
import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js';
@ -22,6 +26,18 @@ const FILTERS = {
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
function seed01(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) % 100000) / 100000;
}
function helpText() {
return [
'Лаборатория карты связей (мок-данные, без сервера).',
@ -30,6 +46,9 @@ function helpText() {
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — прототип глубины 2-3 уровней (фейковые связи, для наглядности).',
' По умолчанию дальние связи скрыты. Зажми/наведи узел — его микро-связи выплывают',
' вокруг, отпусти — втягиваются обратно. Перейдёшь на узел — путь до него горит треком.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
@ -42,6 +61,87 @@ function graphForLogin(login) {
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
}
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
function synthTier1(focusId) {
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
const out = [];
for (let i = 0; i < k; i += 1) {
const id = `${focusId}__t1_${i}`;
const s = seed01(id);
out.push({
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
avatar: null, photo: null,
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
connectionStrength: 0.5 + s * 0.4,
status: s > 0.78 ? 'shining' : '',
hasOwnConnections: true, tier: 1,
});
}
return out;
}
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
function addDeepLevels(model) {
const focusId = model.focusId;
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
const extra = [];
tier1.forEach((p) => {
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: null, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
extra.push({
id: id3, login: id3, name: '', avatar: null, photo: null, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
}
});
return { focusId, nodes: [...model.nodes, ...extra] };
}
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
function buildLabModel(login, deep, fromLogin) {
const tz = graphForLogin(login);
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
else tz.connections = [];
}
const base = buildModelFromTz(tz);
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
const fid = String(fromLogin);
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
if (found) {
found.track = true; // уже среди связей — просто подсветим трек
} else {
const f = graphForLogin(fromLogin).focusUser || {};
base.nodes.push({
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
photo: f.photo || null, relationType: 'friend', strength: 0.97,
shining: false, tier: 1, track: true,
});
}
}
return deep ? addDeepLevels(base) : base;
}
export function renderNetworkLab({ navigate }) {
const screen = document.createElement('section');
screen.className = 'network-screen';
@ -54,17 +154,13 @@ export function renderNetworkLab({ navigate }) {
const header = renderHeader({
title: 'Связи · лаборатория',
leftAction: {
label: '←',
onClick: () => navigate('network-view'),
},
rightActions: [
{ label: '?', onClick: () => window.alert(helpText()) },
],
leftAction: { label: '←', onClick: () => navigate('network-view') },
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
});
header.classList.add('network-header-overlay');
const model = buildModelFromTz(graphForLogin(START_LOGIN));
let centerLogin = START_LOGIN;
let deepMode = false;
// Состояние активного слоя (как в network-view): фокус всегда виден.
let activeFilter = 'all';
@ -82,15 +178,19 @@ export function renderNetworkLab({ navigate }) {
stage.append(header);
screen.append(stage);
const model = buildLabModel(centerLogin, deepMode);
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
stage,
model,
// тап по узлу — переключаем карту на сеть выбранного человека
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
graph.setModel(buildModelFromTz(graphForLogin(node.login)));
// сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
const from = centerLogin; // предыдущий фокус → трек прохождения
centerLogin = node.login || node.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
},
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
@ -108,8 +208,7 @@ export function renderNetworkLab({ navigate }) {
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button');
@ -120,6 +219,18 @@ export function renderNetworkLab({ navigate }) {
filterChips[key] = chip;
filterBar.append(chip);
});
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
const deepChip = document.createElement('button');
deepChip.type = 'button';
deepChip.className = 'fg-filter-chip fg-deep-chip';
deepChip.textContent = '🌌 Вселенная';
deepChip.addEventListener('click', () => {
deepMode = !deepMode;
deepChip.classList.toggle('is-active', deepMode);
graph.setModel(buildLabModel(centerLogin, deepMode));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
});
filterBar.append(deepChip);
stage.append(filterBar);
screen.cleanup = () => {

View File

@ -102,7 +102,7 @@
height: 52px;
margin: 0;
font-size: 16px;
transition: box-shadow 160ms ease, border-color 160ms ease;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
}
.fg-node.is-family .node-dot {
@ -132,10 +132,18 @@
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45);
}
/* Тактильный отклик «нажатия вглубь»: аватарка слегка вдавливается (scale 0.92), а неоновое кольцо
вспыхивает заметно ярче (~1.5×). Срабатывает при наведении, фокусе и зажатии (.is-pressed). */
.fg-node:focus-visible .node-dot,
.fg-node:hover .node-dot {
border-color: rgba(166, 218, 255, 0.95);
box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35);
.fg-node:hover .node-dot,
.fg-node.is-pressed .node-dot {
transform: scale(0.92);
border-color: rgba(160, 240, 255, 0.95);
box-shadow: 0 0 0 2px rgba(150, 238, 255, 0.6), 0 0 22px rgba(120, 230, 255, 0.85);
}
@media (prefers-reduced-motion: reduce) {
.fg-node.is-pressed .node-dot { transform: none; }
}
/* «Сияние» мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
@ -265,6 +273,40 @@
.fg-dot.is-contact { background: #36435c; }
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */
/* 2-й уровень «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени
и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */
.fg-node.is-tier2 .node-dot {
border-color: rgba(150, 180, 220, 0.4);
box-shadow: none;
}
.fg-node.is-tier2 .fg-node-label {
font-size: 9px;
opacity: 0.55;
top: calc(100% + 1px);
}
/* 3-й уровень — микрозвезда: светящаяся точка без картинки (эффект далёкого созвездия). */
.fg-dot.is-tier3 {
width: 9px;
height: 9px;
border: 0;
background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%);
box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5);
}
.fg-dot.is-tier3.is-shine {
background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%);
box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7);
}
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
.fg-deep-chip.is-active {
background: rgba(150, 130, 255, 0.18);
border-color: rgba(190, 170, 255, 0.6);
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3);
color: #efeaff;
}
/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
.fg-reticle {
position: absolute;