SHA256
Связи (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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user