Связи (pixel-aquarium): Умный фокус (Smart Zoom / «аквариум») — погружение в узел + LOD

Новая ветка для безопасного отката (от pixel-web). Режим «Вселенная», только лаборатория.

1. Гибрид клика: 1-й уровень → раскрытие ветки НА МЕСТЕ (как раньше); 2-й уровень+ → ПОГРУЖЕНИЕ.

2. Dive (умный наезд камеры, «аквариум», без перестройки графа):
   - diveTo(node): пинит весь путь (предки до Ивана), ставит diveTargetId + diveZoom=1.7;
     камера в tick плавно ЛЕТИТ и ЗУМИТ, центрируя узел (DIVE_FLY_K), узел ВЫРАСТАЕТ (×2.1 ~ герой).
   - Глубина (contextTargetOf → depthScale/depthBlur/spotCur, лерп): Иван и боковые ветки
     УМЕНЬШАЮТСЯ (root ×0.55) + уходят в BLUR 3px + тускнеют до 0.25 → задний план «аквариума».
   - Нить-крошка: путь Иван→…→узел (divePathSet/onPath) горит ярким «световодом» — виден путь назад.
   - Всплытие: повтор клика по цели → exitDive (камера/зум плавно к корню); клик по Ивану →
     collapseAll (полный сброс + всплытие).

3. Pinch-to-Zoom + LOD 3-го уровня: при zoom≥1.55 видимые точки 3-го уровня дорисовываются как
   читаемые аватарки (лицо+имя; updateLod/setNodeLod — пере-рендер DOM на пороге), при отдалении —
   обратно в светящиеся точки. Узлам tier-3 добавлены фото-заглушки (pravatar) и имена.

Глубина — фейк-3D через масштаб + CSS-blur (GPU), без WebGL. Реальный путь /network-view не затронут:
dive только tier≥2 (в реале их нет), depthScale/Blur нейтральны по умолчанию, updateLod выходит при !hasDeep.
Бамп client.version → 1.2.146.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-09 23:30:44 +03:00
parent f92e6c3cf1
commit 7a8852f64b
4 changed files with 192 additions and 29 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.145
server.version=1.2.129
client.version=1.2.146
server.version=1.2.130

View File

@ -113,8 +113,24 @@
толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно).
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
> ⚠️ Эксперимент на ветке `pixel-web` (для отката). Реальный путь `/network-view` не затронут:
> весь deep-код под `tier ≥ 2` / `hasDeep`, hover-колбэк передаёт только лаборатория.
### Умный фокус (Smart Zoom / «аквариум») — ветка `pixel-aquarium`
Клик по узлу разный по уровню (гибрид): **1-й уровень** — раскрытие ветки НА МЕСТЕ (как выше);
**2-й уровень+** — **погружение (dive)**:
- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K`): узел
плавно центрируется и **вырастает** (`DIVE_TARGET_MUL=2.1` → ~герой-размер).
- **Глубина «аквариума»** (`contextTargetOf` → `depthScale`/`depthBlur`/`spotCur`, лерп): Иван и боковые
ветки **уменьшаются** (root ×0.55, фон ×0.55) + уходят в **blur 3px** + тускнеют до 0.25 → задний план.
- **Нить-крошка**: путь Иван → … → узел (`divePathSet`/`onPath`) горит ярким «световодом» — видно путь назад.
- **Всплытие**: повторный клик по узлу-цели → `exitDive` (камера/зум плавно возвращаются к корню);
клик по Ивану → `collapseAll` (полный сброс + всплытие).
- **Pinch-to-Zoom + LOD**: щипок/колесо меняют `zoom`; при `zoom ≥ LOD_ZOOM (1.55)` видимые точки 3-го
уровня **дорисовываются как аватарки** (лицо+имя, `updateLod`/`setNodeLod` — пере-рендер DOM на пороге),
при отдалении сворачиваются обратно в светящиеся точки.
- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL.
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.
> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2
> (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`.
## Ограничения / на будущее
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»

View File

@ -63,6 +63,18 @@ const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённ
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
// Умный фокус (Smart Zoom / «аквариум»): клик по узлу 2-го уровня — камера летит и зумит к нему,
// он вырастает в центр; Иван и боковые ветки уменьшаются + расфокус (blur) на задний план;
// нить-крошка обратно к Ивану остаётся яркой. При сильном зуме точки 3-го уровня → аватарки (LOD).
const DIVE_ZOOM = 1.7; // зум камеры при погружении
const DIVE_FLY_K = 0.13; // скорость «полёта» камеры/зума к узлу (lerp за кадр)
const DIVE_TARGET_MUL = 2.1; // множитель масштаба «нырнутого» узла (вырастает в герой-центр)
const DIVE_PATH_MUL = 0.72; // предки на пути назад — чуть мельче (видимая «цепочка крошек»)
const DIVE_ROOT_MUL = 0.55; // корень (Иван) уходит вглубь сильнее всех
const DIVE_OFFPATH_MUL = 0.55; // боковые ветки (вне пути) — уменьшаются на задний план
const DIVE_BLUR = 3; // размытие фоновых (вне пути) узлов — эффект расфокуса/глубины, px
const LOD_ZOOM = 1.55; // порог зума, на котором точки 3-го уровня превращаются в аватарки
const RELATION_COLORS = {
family: 'rgba(255, 159, 94, 0.92)',
friend: 'rgba(120, 179, 255, 0.9)',
@ -152,6 +164,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют)
let diveTargetId = null; // id «нырнутого» узла (Smart Zoom); null — мы на верхнем уровне (Иван)
let diveZoom = 1; // целевой зум активного погружения
let surfacing = false; // идёт «всплытие» назад (камера/зум возвращаются к корню)
const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); };
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
@ -169,6 +184,36 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM;
}
// Путь-«крошки» от корня (Иван) до нырнутого узла включительно — для подсветки нити назад и глубины.
function divePathSet() {
const set = new Set();
if (!diveTargetId) return set;
let cur = nodeById.get(diveTargetId); let guard = 0;
while (cur && guard++ < 16) { set.add(String(cur.id)); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
set.add(String(focusId));
return set;
}
let _pathSet = new Set();
let _pathSetKey = '';
function ensurePathSet() {
const k = diveTargetId || '';
if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); }
return _pathSet;
}
// Контекст узла: целевые { прозрачность, множитель масштаба, размытие } для эффекта «аквариума»/spotlight.
// Погружение (dive) имеет приоритет: нырнутый узел крупный/чёткий, путь назад — чёткий, фон — мелкий/blur.
function contextTargetOf(n) {
if (diveTargetId) {
const ps = ensurePathSet();
const id = String(n.id);
if (id === diveTargetId) return { op: 1, scale: DIVE_TARGET_MUL, blur: 0 }; // герой-центр
if (String(n.parentId) === diveTargetId) return { op: 1, scale: 1, blur: 0 }; // его дети — новая ветка
if (ps.has(id)) return { op: 1, scale: n.isFocus ? DIVE_ROOT_MUL : DIVE_PATH_MUL, blur: 0 }; // путь назад
return { op: SPOTLIGHT_DIM, scale: DIVE_OFFPATH_MUL, blur: DIVE_BLUR }; // фон (расфокус)
}
return { op: spotTargetOf(n), scale: 1, blur: 0 }; // без погружения — обычный spotlight
}
// Управление циклом rAF
let rafId = 0;
let dragging = false;
@ -278,6 +323,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
opacity: tier >= 2 ? op : 1,
targetOpacity: op,
spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет)
depthScale: 1, // множитель масштаба «глубины» (dive: цель крупно, фон мелко)
depthBlur: 0, // размытие «глубины» (dive: фон уходит в расфокус), px
lod: (dotOnly && tier >= 3) ? 'dot' : 'full', // уровень детализации tier-3: точка ↔ аватарка (по зуму)
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
@ -362,6 +410,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
node.track = Boolean(src.track);
node.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается
node.depthScale = 1; node.depthBlur = 0; node.lod = (spec.dotOnly && tier >= 3) ? 'dot' : 'full';
node.dotOnly = spec.dotOnly;
node.strength = strength;
node.relationType = src.relationType;
@ -430,9 +479,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function renderNodes() {
for (const n of nodes) {
const ds = n.depthScale ?? 1;
n.el.style.transform =
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`;
n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
// расфокус глубины (dive): фоновые узлы уходят в blur; чёткие — без фильтра (дёшево)
n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
}
}
@ -452,7 +504,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42)
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
// вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий)
const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1;
n.opacity = baseOp * e * parentVis;
@ -486,11 +539,37 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
const ds = n.depthScale ?? 1;
n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
}
}
// LOD (level-of-detail): при сильном зуме (pinch/dive) видимые точки 3-го уровня дорисовываются как
// маленькие аватарки (лицо+имя), при отдалении — сворачиваются обратно в светящиеся точки.
// Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая.
function updateLod() {
if (!hasDeep) return;
const wantFullGlobal = zoom >= LOD_ZOOM;
for (const n of nodes) {
if (n.tier < 3) continue; // LOD касается только микрозвёзд 3-го уровня
const wantFull = wantFullGlobal && (n.opacity > 0.04); // апгрейдим только видимые
if (wantFull === (n.lod === 'full')) continue;
setNodeLod(n, wantFull);
}
}
function setNodeLod(n, full) {
const newEl = buildNodeElement(n, false, n.tier, !full); // n несёт src-поля (photo/name/...) через spread
newEl.style.transform = n.el.style.transform; // без скачка к (0,0) на один кадр
newEl.style.opacity = n.el.style.opacity;
world.replaceChild(newEl, n.el);
n.el = newEl;
n.lod = full ? 'full' : 'dot';
n.dotOnly = !full;
n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей
}
function renderEdges() {
const focus = nodes.find((n) => n.id === focusId);
if (!focus) {
@ -517,7 +596,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
const fx = centerX + camX + parent.x * Z;
const fy = centerY + camY + parent.y * Z;
const fr = parent.dotRadius * parent.scale * Z + 4;
const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4;
const nx = tx(n);
const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
@ -534,7 +613,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const nr = n.dotRadius * n.scale * Z + 4;
const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4;
// концы линии — у краёв кружков
const x1 = fx + ux * fr;
const y1 = fy + uy * fr;
@ -567,7 +646,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
const shine = Boolean(n.shining) && !n.hidden;
const sp = (n.spotCur ?? 1); // spotlight: линия тускнеет вместе со своим узлом (затемнённые ветки)
const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом
const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
// ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
// (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
@ -586,9 +666,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
} 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 * sp).toFixed(2)}" />`);
} else if (shine || n.track) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
} else if (shine || n.track || onPath) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется для сияющих, «трека
// прохождения» (n.track) И нити-крошки пути погружения (onPath) — путь назад к Ивану горит ярко.
const cOpVal = nodeOpacity * sp;
const gOp = (0.4 * cOpVal).toFixed(2);
const cOpAttr = cOpVal < 0.995 ? ` opacity="${cOpVal.toFixed(2)}"` : '';
@ -669,23 +749,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return totalV;
}
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание») + spotlight.
// Плавное приближение масштаба/прозрачности к целям + рост линии + spotlight + глубина (dive).
function advanceVisual() {
spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют
for (const n of nodes) {
n.scale += (n.targetScale - n.scale) * 0.2;
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
n.spotCur += (spotTargetOf(n) - n.spotCur) * 0.2; // плавное затемнение/прояснение веток
const c = contextTargetOf(n); // {op, scale, blur} — spotlight или глубина dive
n.spotCur += (c.op - n.spotCur) * 0.2; // затемнение/прояснение
n.depthScale += (c.scale - n.depthScale) * 0.2; // dive: цель крупно / фон мелко
n.depthBlur += (c.blur - n.depthBlur) * 0.2; // dive: фон уходит в расфокус
// линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
}
}
// Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки).
// Не «успокоились» ли ещё визуальные параметры/рост линий/глубина (для условия заморозки).
function visualSettling() {
for (const n of nodes) {
const c = contextTargetOf(n);
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01
|| Math.abs(n.spotCur - spotTargetOf(n)) > 0.01 || n.edgeGrow < 1) return true;
|| Math.abs(n.spotCur - c.op) > 0.01 || Math.abs(n.depthScale - c.scale) > 0.01
|| Math.abs(n.depthBlur - c.blur) > 0.4 || n.edgeGrow < 1) return true;
}
return false;
}
@ -904,6 +989,32 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
applyWorldTransform();
}
// Smart Zoom: dive-камера летит и зумит к нырнутому узлу (аквариумный наезд), центрируя его;
// всплытие (surfacing) — плавный возврат камеры/зума к корню. Любой жест (drag/pan/pinch) приостанавливает.
let diveCamActive = false;
if (!dragging && !panActive && !pinching) {
if (diveTargetId) {
const t = nodeById.get(diveTargetId);
if (t) {
zoom += (diveZoom - zoom) * DIVE_FLY_K;
const desX = -t.x * zoom; // узел → центр экрана (screen = center + cam + x*zoom = center)
const desY = -t.y * zoom;
camX += (desX - camX) * DIVE_FLY_K;
camY += (desY - camY) * DIVE_FLY_K;
applyWorldTransform();
if (Math.abs(zoom - diveZoom) > 0.004 || Math.abs(desX - camX) > 0.4 || Math.abs(desY - camY) > 0.4) diveCamActive = true;
}
} else if (surfacing) {
zoom += (1 - zoom) * DIVE_FLY_K;
camX += (0 - camX) * DIVE_FLY_K;
camY += (0 - camY) * DIVE_FLY_K;
applyWorldTransform();
if (Math.abs(zoom - 1) < 0.004 && Math.abs(camX) < 0.4 && Math.abs(camY) < 0.4) { zoom = 1; camX = 0; camY = 0; surfacing = false; }
else diveCamActive = true;
}
}
updateLod(); // LOD: точки 3-го уровня ↔ аватарки по текущему зуму
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
@ -923,7 +1034,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
renderAll();
const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
if (tween || dragging || panActive || camGliding || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
if (tween || dragging || panActive || camGliding || diveCamActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
schedule();
} else {
freezeGraph(); // система успокоилась — замираем
@ -983,14 +1094,40 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
if (changed) wake();
}
// Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток (тап по корню — Ивану).
// Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток + всплываем из погружения (тап по Ивану).
function collapseAll() {
let any = false;
for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } }
if (diveTargetId) { diveTargetId = null; surfacing = true; any = true; } // всплыть наверх
if (any) haptic(14);
wake();
}
// Умный фокус (Smart Zoom): погружение в узел 2-го+ уровня. Камера летит/зумит к нему (в tick),
// узел вырастает в центр, путь назад к Ивану остаётся ярким, фон уходит в расфокус (см. contextTargetOf).
function diveTo(node) {
const n = node && (nodeById.get(String(node.id)) || node);
if (!n || n.isFocus || n.tier < 2) return; // ныряем только в глубокие узлы
if (diveTargetId === String(n.id)) { exitDive(); return; } // повтор по цели — всплыть назад
// раскрываем весь путь (предки до Ивана) — чтобы цель и её дети гарантированно были видимы
let cur = n; let guard = 0;
while (cur && guard++ < 16) { if (!cur.isFocus) cur.pinned = true; if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; }
diveTargetId = String(n.id);
diveZoom = DIVE_ZOOM;
surfacing = false;
camTargetX = null; camTargetY = null; // dive-камера центрирует сама
haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс
wake();
}
// Всплытие: выходим из погружения на уровень назад (камера и зум плавно возвращаются к корню).
function exitDive() {
if (!diveTargetId) return;
diveTargetId = null;
surfacing = true;
haptic(10);
wake();
}
function nodeFromEvent(ev) {
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
if (!el) return null;
@ -1279,6 +1416,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return node;
});
pendingFocusOrigin = null;
diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум
rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов
layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей
renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает)
@ -1326,7 +1464,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
setFilter,
toggleExpand, // mind-map: ЗАФИКСИРОВАТЬ/снять раскрытие ветки кликом (pinned)
setHover, // mind-map: ВРЕМЕННОЕ раскрытие ветки наведением (node | null)
collapseAll, // mind-map: свернуть все ветки (тап по корню)
diveTo, // Smart Zoom: погрузиться в узел 2-го+ уровня (наезд камеры, «аквариум»)
exitDive, // Smart Zoom: всплыть из погружения на уровень назад
collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню)
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
destroy() {
if (rafId) cancelAnimationFrame(rafId);

View File

@ -47,9 +47,12 @@ function helpText() {
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' По умолчанию дальние связи скрыты. Наведи мышь/палец на узел — его микро-связи временно',
' выплывают (превью), убери — втягиваются. Кликни/тапни узел — раскрытие ФИКСИРУЕТСЯ.',
' Тап по центру (Ивану) — свернуть все ветки. Колесо мыши / щипок — зум. Свайп — pan.',
' Наведи мышь/палец на узел 1-го уровня — его микро-связи временно выплывают (превью);',
' клик/тап — раскрытие ФИКСИРУЕТСЯ. Клик по маленькому узлу 2-го уровня — «умный наезд»:',
' камера летит к нему, он вырастает в центр, Иван уходит вглубь (нить-крошка назад горит).',
' Клик по нему ещё раз — всплыть назад. Тап по центру (Ивану) — полный сброс.',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
@ -104,8 +107,11 @@ function addDeepLevels(model) {
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
extra.push({
id: id3, login: id3, name: '', avatar: null, photo: null, relationType: 'contact',
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
avatar: null, photo: face3, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
@ -192,9 +198,10 @@ export function renderNetworkLab({ navigate }) {
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
if (deepMode) {
// режим «Интерактивная паутина»: НЕ меняем центр — клик ФИКСИРУЕТ раскрытие ветки (остаётся
// открытой и после ухода курсора); повторный клик снимает фиксацию.
graph.toggleExpand(node);
// 2-й уровень+ → Smart Zoom (умный наезд камеры, «аквариум»): узел выплывает в центр,
// Иван уходит вглубь, нить-крошка назад горит. 1-й уровень → раскрытие ветки НА МЕСТЕ.
if ((node.tier || 1) >= 2) graph.diveTo(node);
else graph.toggleExpand(node);
return;
}
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)