From 9a49cc67f04867c56c857c6d1cf0c8aeabcbd338c9dac1c466c539d79bd201ad Mon Sep 17 00:00:00 2001 From: Pixel Date: Wed, 10 Jun 2026 00:25:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-aquariu?= =?UTF-8?q?m,=2010.06):=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=8F=201=20(?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B8=D1=88)=20+=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Усиления (движок-полиш) с детерминированной самопроверкой: - Веер детей — полукругом «наружу» (DEEP_FAN, по sibIndex от направления деда→родитель): не перекрывает нить-крошку и родителя; равномерное распределение. - LOD с гистерезисом (LOD_ZOOM_UP=1.6 / DOWN=1.4) — точки 3-го уровня ↔ аватарки без «мигания» у порога. - Двойной тап по пустому фону и сильный pinch-out на минимальном зуме = быстрый выход из погружения. - Префетч аватарок детей при наведении/нырке (prefetchChildren) — лица в кэше до раскрытия. Автопроверки (dev-only, ТОЛЬКО при ?fgtest): - js/pages/network/selftest.js — 14 ассертов: камера-центровка, collision (нет слипания), полукруг, spotlight (путь 1.0 / фон 0.25 / сброс при переключении / 100% на выходе), LOD, возврат зума. - Движок: read-only graph.debugState() + graph.pumpForTest() (синхронно докручивает кадры до покоя, не зависит от троттлинга rAF в фоне). Граф как window.__fg — тоже только при ?fgtest. - Прогон: 14/14 PASS (offset 0px, мин.дистанция детей 89px, веер ±99°, LOD 4/4). В обычной работе тест-хелперы не активны. Реальный путь /network-view не затронут. Бамп client → 1.2.148. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION.properties | 4 +- .../features/interactive-network-graph.md | 10 ++ shine-UI/js/pages/network/force-graph.js | 78 +++++++++++-- shine-UI/js/pages/network/lab.js | 6 + shine-UI/js/pages/network/selftest.js | 105 ++++++++++++++++++ 5 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 shine-UI/js/pages/network/selftest.js diff --git a/VERSION.properties b/VERSION.properties index 84bb69a..30e3fe1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.147 -server.version=1.2.131 +client.version=1.2.148 +server.version=1.2.132 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 9ce4004..28481ff 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -132,6 +132,16 @@ уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки. - Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL. +**Полиш (партия 1):** веер детей раскрывается **полукругом «наружу»** (от пути назад, `DEEP_FAN`, +по `sibIndex`) — не перекрывает нить-крошку; **LOD с гистерезисом** (`LOD_ZOOM_UP=1.6`/`DOWN=1.4` — без +мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход; +**префетч аватарок** детей при наведении/нырке. + +**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`, +прогоняет 14 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/выход) через детерминированные +dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры до покоя — не +зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны. + > ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката. > Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2 > (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`. diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index 96cff89..fed2958 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -74,7 +74,11 @@ 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-го уровня превращаются в аватарки +// LOD с гистерезисом (без «мигания» у порога): апгрейд точка→аватарка на UP, откат на DOWN (зазор). +const LOD_ZOOM_UP = 1.6; // зум, на котором точки 3-го уровня превращаются в аватарки +const LOD_ZOOM_DOWN = 1.4; // зум, ниже которого аватарки сворачиваются обратно в точки +const DEEP_FAN = Math.PI * 1.1; // ширина веера детей: полукруг «наружу» от пути назад (~198°) +const DOUBLE_TAP_MS = 320; // окно двойного тапа по фону (быстрый сброс погружения) const RELATION_COLORS = { family: 'rgba(255, 159, 94, 0.92)', @@ -172,8 +176,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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); } + for (const n of nodes) { + if (n.tier >= 2 && n.parentId) { + const i = childCountByParent.get(n.parentId) || 0; + n.sibIndex = i; + childCountByParent.set(n.parentId, i + 1); + } + } }; // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы @@ -521,10 +532,18 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL 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 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 gp = nodeById.get(p.parentId); + const ox = p.x - (gp ? gp.x : 0); + const oy = p.y - (gp ? gp.y : 0); + const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол) + const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0; // -0.5..0.5 по веера + const ang = outward + t * DEEP_FAN; + n.x = p.x + Math.cos(ang) * r; + n.y = p.y + Math.sin(ang) * r; const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY; // tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42) const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1); @@ -573,11 +592,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Пере-рендер 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; + const isFull = n.lod === 'full'; + // гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания) + const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP; + const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые + if (wantFull === isFull) continue; setNodeLod(n, wantFull); } } @@ -1085,9 +1106,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let pinching = false; // активен щипок (pan/tap на это время заморожены) let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки) + let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс) // Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует. const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } }; + // Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера. + const prefetched = new Set(); + function prefetchChildren(node) { + if (!node) return; + const pid = String(node.id); + for (const m of nodes) { + if (String(m.parentId) === pid && m.photo && !prefetched.has(m.photo)) { + prefetched.add(m.photo); + try { const im = new Image(); im.decoding = 'async'; im.src = m.photo; } catch { /* нет Image — не критично */ } + } + } + } + // Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см. // advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение). // Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся @@ -1114,6 +1149,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const want = n === target; if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; } } + if (target) prefetchChildren(target); // подгружаем лица детей заранее (до клика) if (changed) wake(); } // Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток + всплываем из погружения (тап по Ивану). @@ -1140,6 +1176,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL diveZoom = DIVE_ZOOM; surfacing = false; camTargetX = null; camTargetY = null; // dive-камера центрирует сама + prefetchChildren(n); // подгружаем лица детей заранее haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс wake(); } @@ -1224,8 +1261,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const rect = stage.getBoundingClientRect(); const mx = (pts[0].x + pts[1].x) / 2 - rect.left; const my = (pts[0].y + pts[1].y) / 2 - rect.top; + const prevDist = pinchDist0; setZoom(zoom * (dist / pinchDist0), mx, my); pinchDist0 = dist; + // сильный pinch-out (пальцы сходятся) на минимальном зуме во время погружения = всплыть назад + if (diveTargetId && dist < prevDist && zoom <= ZOOM_MIN + 0.02) exitDive(); wake(); return; } @@ -1283,7 +1323,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const tapNode = downNodeEl ? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId)) : null; - if (!tapNode) return; + if (!tapNode) { + // тап по пустому фону: двойной быстрый тап = сброс погружения/раскрытия (всплыть на весь граф) + const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + if (now && now - lastBgTapTs < DOUBLE_TAP_MS) { lastBgTapTs = 0; if (diveTargetId || spotActive) collapseAll(); } + else lastBgTapTs = now; + return; + } if (tapNode.isFocus) { if (typeof onCenterTap === 'function') onCenterTap(tapNode); return; @@ -1493,6 +1539,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL exitDive, // Smart Zoom: всплыть из погружения на уровень назад collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню) getFocusNode: () => nodes.find((n) => n.isFocus) || null, + // --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) ------------------- + // Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера. + debugState: () => ({ + zoom: +zoom.toFixed(3), camX: Math.round(camX), camY: Math.round(camY), diveTargetId, surfacing, spotActive, focusId, + nodes: nodes.map((n) => ({ id: String(n.id), tier: n.tier, lod: n.lod, pinned: !!n.pinned, hovered: !!n.hovered, sibIndex: n.sibIndex, expandP: +(n.expandP || 0).toFixed(3), x: Math.round(n.x), y: Math.round(n.y), scale: +(n.scale || 0).toFixed(3), depthScale: +(n.depthScale || 1).toFixed(3), depthBlur: +(n.depthBlur || 0).toFixed(2), opacity: +(n.opacity || 0).toFixed(3), spotCur: +(n.spotCur || 1).toFixed(3) })), + }), + // Детерминированно докрутить анимацию до покоя (обходит троттлинг rAF в фоновых вкладках/тестах). + pumpForTest: (maxFrames = 1200) => { + let ts = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + let i = 0; + for (; i < maxFrames; i += 1) { ts += 16; tick(ts); if (!rafId) break; } // tick заморозился → rafId=0 + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + return i + 1; + }, destroy() { if (rafId) cancelAnimationFrame(rafId); rafId = 0; diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 52de663..43d7899 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -256,6 +256,12 @@ export function renderNetworkLab({ navigate }) { filterBar.append(deepChip); stage.append(filterBar); + // Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль). + if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) { + window.__fg = graph; + import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e)); + } + screen.cleanup = () => { graph.destroy(); appScreenEl?.classList.remove('network-scroll-lock'); diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js new file mode 100644 index 0000000..13aeabe --- /dev/null +++ b/shine-UI/js/pages/network/selftest.js @@ -0,0 +1,105 @@ +// Автопроверки интерактивного графа связей (dev-only). +// +// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные +// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда +// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя). +// +// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }. + +const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас) + +export async function runNetworkSelfTest(graph, deepChipEl) { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const results = []; + const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); }; + const st = () => graph.debugState(); + + // 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout). + if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click(); + await wait(1700); + + let s = st(); + const focusId = s.focusId; + const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId); + const parent = tier1.find((n) => n.id === 'nina') || tier1[0]; + if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); } + + // === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) === + graph.diveTo({ id: parent.id }); + const framesA = graph.pumpForTest(); + s = st(); + const p = s.nodes.find((n) => n.id === parent.id); + const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_')); + + check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`); + check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`); + check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36, + `offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`); + check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`); + + // расталкивание: дети не слипаются + let minD = Infinity; + for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) { + minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y)); + } + check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`); + + // полукруг наружу: все дети в секторе вокруг направления от центра к родителю + const outward = Math.atan2(p.y, p.x); + const maxDev = kids.reduce((mx, k) => { + let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward); + if (d > Math.PI) d = 2 * Math.PI - d; + return Math.max(mx, d * 180 / Math.PI); + }, 0); + check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`); + + // === Тест B: Spotlight открыт — путь горит, остальное тускнеет === + const offPath = tier1.filter((n) => n.id !== parent.id); + const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; }); + const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9); + check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9'); + check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4'); + + // === Тест C: переключение веток сбрасывает прежнюю (нет накопления) === + if (offPath.length) { + graph.diveTo({ id: offPath[0].id }); + graph.pumpForTest(); + s = st(); + const prev = s.nodes.find((n) => n.id === parent.id); + check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`); + check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`); + } + + // === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме === + const t2withKids = st().nodes.find((n) => n.tier === 2); + if (t2withKids) { + graph.diveTo({ id: t2withKids.id }); + graph.pumpForTest(); + s = st(); + const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_')); + const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true; + check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`); + } + + // === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает === + graph.exitDive(); + graph.pumpForTest(); + s = st(); + const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95); + check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95'); + check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`); + check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`); + + return finish(results); +} + +function finish(results) { + const pass = results.filter((r) => r.pass).length; + const out = { pass, total: results.length, results }; + if (typeof window !== 'undefined') window.__fgTestResults = out; + const tag = pass === results.length ? '✅ PASS' : '❌ FAIL'; + // eslint-disable-next-line no-console + console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`); + results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`)); + return out; +}