Связи (pixel-aquarium, 10.06): фикс физики раскрытия + камера-наезд + железный spotlight

Исправлены критические баги «аквариума» по разбору (видео):

1. Слипание узлов → адаптивный радиус орбиты (фикс collision). Дети раскладываются на кольце
   ringR = max(baseR + радиус_родителя, число_детей×13): не лезут на (увеличенный зумом) родитель
   и друг на друга. Проверено: мин. дистанция 125px, 0 наложений (было — все в одной точке).

2. Умный наезд камеры на КЛИК по любому узлу (раньше 1-й уровень раскрывался на месте). diveTo
   центрирует узел (offset ~0), zoom 1.7; узел и дети растут до единого видимого размера
   (HERO_VISUAL/baseScaleOf, DIVE_CHILD_VISUAL) — крупно и читаемо. Наведение остаётся лёгким превью.

3. Железный Spotlight (единый активный путь): diveTo гасит ВСЕ прежние pin/hover, затем раскрывает
   только путь к цели. Открыто → путь=1.0, остальное=0.25; переключение веток сбрасывает прежнюю;
   exitDive/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает. (Проверено программно.)

Реальный путь /network-view не затронут (вся глубина под tier≥2/hasDeep). Бамп client.version → 1.2.147.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-10 00:12:27 +03:00
parent 7a8852f64b
commit 3012f0799b
4 changed files with 59 additions and 31 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.146
server.version=1.2.130
client.version=1.2.147
server.version=1.2.131

View File

@ -114,18 +114,22 @@
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
### Умный фокус (Smart Zoom / «аквариум») — ветка `pixel-aquarium`
Клик по узлу разный по уровню (гибрид): **1-й уровень** — раскрытие ветки НА МЕСТЕ (как выше);
**2-й уровень+** — **погружение (dive)**:
- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K`): узел
плавно центрируется и **вырастает** (`DIVE_TARGET_MUL=2.1` → ~герой-размер).
**Наведение** (hover/палец) на узел — лёгкое превью ветки (раскрытие на месте, без камеры).
**Клик/тап по ЛЮБОМУ узлу** — **погружение (dive)** с кинематографичным наездом:
- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K` ≈600мс):
узел плавно центрируется (offset ~0) и **вырастает до единого видимого размера** `HERO_VISUAL=1.4`
независимо от уровня (`depthScale = HERO_VISUAL / baseScaleOf`); его прямые дети — до `DIVE_CHILD_VISUAL`.
- **Адаптивный радиус орбиты (фикс слипания):** дети раскладываются на кольце
`ringR = max(baseR + радиус_родителя, числоетей × 13)`НЕ лезут на (увеличенный зумом) родитель
и друг на друга (проверено: мин. дистанция 125px, 0 наложений). Радиус растёт вместе с зумом родителя.
- **Глубина «аквариума»** (`contextTargetOf` → `depthScale`/`depthBlur`/`spotCur`, лерп): Иван и боковые
ветки **уменьшаются** (root ×0.55, фон ×0.55) + уходят в **blur 3px** + тускнеют до 0.25 → задний план.
- **Нить-крошка**: путь Иван → … → узел (`divePathSet`/`onPath`) горит ярким «световодом» — видно путь назад.
- **Всплытие**: повторный клик по узлу-цели → `exitDive` (камера/зум плавно возвращаются к корню);
клик по Ивану → `collapseAll` (полный сброс + всплытие).
- **Железный Spotlight (единый активный путь):** `diveTo` сначала гасит ВСЕ прежние pin/hover, затем
раскрывает только путь к новой цели. Открыто → путь Иван→…→узел = 1.0, остальное = 0.25; переключение
веток сбрасывает прежнюю; **выход/`exitDive`/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает**.
- **Нить-крошка**: путь (`divePathSet`/`onPath`) горит ярким «световодом» — виден путь назад к Ивану.
- **Pinch-to-Zoom + LOD**: щипок/колесо меняют `zoom`; при `zoom ≥ LOD_ZOOM (1.55)` видимые точки 3-го
уровня **дорисовываются как аватарки** (лицо+имя, `updateLod`/`setNodeLod` — пере-рендер DOM на пороге),
при отдалении сворачиваются обратно в светящиеся точки.
уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки.
- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL.
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.

View File

@ -66,9 +66,10 @@ const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при
// Умный фокус (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_ZOOM = 1.7; // зум камеры при погружении (наезд ~600мс)
const DIVE_FLY_K = 0.13; // скорость «полёта» камеры/зума к узлу (lerp за кадр) ≈ 600мс до цели
const HERO_VISUAL = 1.4; // желаемый ВИДИМЫЙ масштаб нырнутого узла — одинаков для tier-1/2/3 (читаемо)
const DIVE_CHILD_VISUAL = 0.95; // желаемый видимый масштаб его прямых детей (крупно/читаемо)
const DIVE_PATH_MUL = 0.72; // предки на пути назад — чуть мельче (видимая «цепочка крошек»)
const DIVE_ROOT_MUL = 0.55; // корень (Иван) уходит вглубь сильнее всех
const DIVE_OFFPATH_MUL = 0.55; // боковые ветки (вне пути) — уменьшаются на задний план
@ -167,7 +168,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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); };
let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания)
const rebuildIndex = () => {
nodeById = new Map(nodes.map((n) => [String(n.id), n]));
hasDeep = nodes.some((n) => n.tier >= 2);
childCountByParent = new Map();
for (const n of nodes) { if (n.tier >= 2 && n.parentId) childCountByParent.set(n.parentId, (childCountByParent.get(n.parentId) || 0) + 1); }
};
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
// взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка
@ -200,14 +207,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); }
return _pathSet;
}
// Базовый масштаб узла по его роли/уровню (как в makeNodeState) — чтобы привести героя и его детей
// к ОДИНАКОВОМУ видимому размеру независимо от tier (depthScale = желаемый_видимый / базовый).
function baseScaleOf(n) {
if (n.isFocus) return FOCUS_SCALE;
if (n.tier >= 3) return n.lod === 'full' ? 0.42 : 1;
if (n.tier === 2) return DEEP2_SCALE;
return PRIMARY_SCALE;
}
// Контекст узла: целевые { прозрачность, множитель масштаба, размытие } для эффекта «аквариума»/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 (id === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.8, HERO_VISUAL / baseScaleOf(n))), blur: 0 };
if (String(n.parentId) === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.2, DIVE_CHILD_VISUAL / baseScaleOf(n))), 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 }; // фон (расфокус)
}
@ -500,7 +516,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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 — на орбите
// АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель
// и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей.
const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
const cnt = childCountByParent.get(n.parentId) || 1;
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: круговой «полукруг/орбита» без наложений
const r = ringR * 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;
@ -1107,9 +1129,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// узел вырастает в центр, путь назад к Ивану остаётся ярким, фон уходит в расфокус (см. 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; } // повтор по цели — всплыть назад
// раскрываем весь путь (предки до Ивана) — чтобы цель и её дети гарантированно были видимы
if (!n || n.isFocus) return; // в сам фокус (Иван) не ныряем
if (diveTargetId === String(n.id)) { exitDive(); return; } // повтор по цели — всплыть назад (полный сброс)
// ЕДИНЫЙ активный путь (железный spotlight): гасим ВСЕ прежние фиксации/ховеры, затем раскрываем
// только путь к новой цели (предки до Ивана) — чтобы цель и её дети были видимы, прочее не «копилось».
for (const m of nodes) { m.pinned = false; m.hovered = false; }
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);
@ -1119,9 +1143,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс
wake();
}
// Всплытие: выходим из погружения на уровень назад (камера и зум плавно возвращаются к корню).
// Всплытие/закрытие ветки: ПОЛНЫЙ сброс — снимаем все фиксации/ховеры (дети втягиваются),
// камера и зум плавно возвращаются на весь граф, ВСЕ узлы гарантированно вернут opacity 1.
function exitDive() {
if (!diveTargetId) return;
for (const m of nodes) { m.pinned = false; m.hovered = false; }
diveTargetId = null;
surfacing = true;
haptic(10);

View File

@ -47,10 +47,10 @@ function helpText() {
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' Наведи мышь/палец на узел 1-го уровня его микро-связи временно выплывают (превью);',
' клик/тап — раскрытие ФИКСИРУЕТСЯ. Клик по маленькому узлу 2-го уровня — «умный наезд»:',
' камера летит к нему, он вырастает в центр, Иван уходит вглубь (нить-крошка назад горит).',
' Клик по нему ещё раз — всплыть назад. Тап по центру (Ивану) — полный сброс.',
' Наведи мышь/палец на узел его связи временно выплывают (превью). КЛИК по узлу — «умный',
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'',
@ -198,10 +198,9 @@ export function renderNetworkLab({ navigate }) {
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
if (deepMode) {
// 2-й уровень+ → Smart Zoom (умный наезд камеры, «аквариум»): узел выплывает в центр,
// Иван уходит вглубь, нить-крошка назад горит. 1-й уровень → раскрытие ветки НА МЕСТЕ.
if ((node.tier || 1) >= 2) graph.diveTo(node);
else graph.toggleExpand(node);
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
graph.diveTo(node);
return;
}
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)