From 7a8852f64bd1eb8cb65983538987df4770719d00e846e7d7b27eb542bbbe317d Mon Sep 17 00:00:00 2001 From: Pixel Date: Tue, 9 Jun 2026 23:30:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-aquariu?= =?UTF-8?q?m):=20=D0=A3=D0=BC=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D1=81=20(Smart=20Zoom=20/=20=C2=AB=D0=B0=D0=BA=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D1=83=D0=BC=C2=BB)=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D0=BB=20+=20LOD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая ветка для безопасного отката (от 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) --- VERSION.properties | 4 +- .../features/interactive-network-graph.md | 20 +- shine-UI/js/pages/network/force-graph.js | 176 ++++++++++++++++-- shine-UI/js/pages/network/lab.js | 21 ++- 4 files changed, 192 insertions(+), 29 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 5b9a6ee..36fab05 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.145 -server.version=1.2.129 +client.version=1.2.146 +server.version=1.2.130 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 5ef348d..75aa80d 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -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-й уровень — точки), кластеры, «общие связи» diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index a66f74d..320301f 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -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(``); - } 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); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 57f18d8..64d5009 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -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; } // обычный режим: перецентрирование на выбранного человека (+ трек прохождения)