Связи (pixel-aquarium, 10.06): партия 1 (полиш) + автопроверки графа

Усиления (движок-полиш) с детерминированной самопроверкой:
- Веер детей — полукругом «наружу» (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) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-10 00:25:57 +03:00
parent 3012f0799b
commit 9a49cc67f0
5 changed files with 192 additions and 11 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.147
server.version=1.2.131
client.version=1.2.148
server.version=1.2.132

View File

@ -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`.

View File

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

View File

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

View File

@ -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;
}