Связи (pixel-web): фикс багов паутины — видимый 2-й уровень, удалён «прицел», toggle+spotlight

Исправлены замечания по видео (режим «Вселенная», только лаборатория):

1. «Невидимые друзья» (узлы 2-го уровня). Раньше — тусклые пустые кружки с инициалом.
   Теперь tier-2 — полноценные аватарки: фото-лицо (pravatar) + имя, DEEP2_SCALE 0.5→0.62
   (≈radius 16px), DEEP2_OPACITY 0.4→0.85; читаемый ободок и подпись (CSS).

2. Мусорный «прицел» (пунктирное кольцо у центра). Полностью удалён из движка:
   элемент .fg-reticle, updateReticle/pulseReticle и все их вызовы, CSS .fg-reticle*.
   На экране только аватарки и линии связей.

3. Логика toggle + spotlight:
   - Повторный клик по раскрытому узлу теперь СВОРАЧИВАЕТ его (isOpen = pinned || expandP>0.5
     → сброс pinned+hovered) — работает, даже если ветка была раскрыта ховером.
   - Spotlight: при закреплённой ветке остальные тускнеют до 0.25 (узлы и линии), фокус и
     закреплённая/наведённая ветка — 100%; плавно через spotCur (lerp, см. spotTargetOf).
   - Клик по центру (Иван) — collapseAll + возврат всему графу 100% яркости.

Реальный путь /network-view не затронут (deep-код под tier≥2/hasDeep). Ветка экспериментальная.
Бамп client.version → 1.2.145.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-09 22:55:10 +03:00
co-authored by Claude Opus 4.8
parent 72dc83daff
commit f92e6c3cf1
5 changed files with 63 additions and 90 deletions
+45 -44
View File
@@ -44,8 +44,8 @@ const MAX_FULL_NODES = 90; // хард-лимит полных DOM-ават
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше
const DEEP2_OPACITY = 0.4; // и полупрозрачный
const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем
const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка»
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
@@ -58,6 +58,7 @@ const ZOOM_WHEEL = 0.0016; // чувствительность колеса
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
@@ -131,11 +132,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.setAttribute('class', 'fg-edges');
const world = document.createElement('div');
world.className = 'fg-world';
// «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется,
// когда под него попадает узел (визуальная зона фокуса при свободном панорамировании).
const reticle = document.createElement('div');
reticle.className = 'fg-reticle';
stage.append(edgesSvg, world, reticle);
stage.append(edgesSvg, world);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование + зум)
@@ -154,8 +151,24 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let focusId = '';
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки)
let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют)
const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); };
// Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы
// взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка
// 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности.
function rootTier1(n) {
let r = n; let guard = 0;
while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null;
return r;
}
function spotTargetOf(n) {
if (!spotActive || n.isFocus) return 1;
const r = rootTier1(n);
if (!r) return SPOTLIGHT_DIM;
return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM;
}
// Управление циклом rAF
let rafId = 0;
let dragging = false;
@@ -264,6 +277,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
hidden: false,
opacity: tier >= 2 ? op : 1,
targetOpacity: op,
spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет)
bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el,
@@ -347,7 +361,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.parentId = String(src.parentId || '');
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.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается
node.dotOnly = spec.dotOnly;
node.strength = strength;
node.relationType = src.relationType;
@@ -406,7 +420,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
applyWorldTransform();
renderEdges();
updateReticle();
}
function onWheel(ev) {
ev.preventDefault();
@@ -419,7 +432,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) {
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.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
}
}
@@ -474,7 +487,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее
}
}
@@ -554,6 +567,7 @@ 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 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 = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
@@ -568,15 +582,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) {
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe).toFixed(2)}" />`);
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe * sp).toFixed(2)}" />`);
} else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe).toFixed(2)}" />`);
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe * sp).toFixed(2)}" />`);
} else if (shine || n.track) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
const gOp = (0.4 * nodeOpacity).toFixed(2);
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
const cOpVal = nodeOpacity * sp;
const gOp = (0.4 * cOpVal).toFixed(2);
const cOpAttr = cOpVal < 0.995 ? ` opacity="${cOpVal.toFixed(2)}"` : '';
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`);
} else {
@@ -590,28 +605,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
+ `<stop offset="1" stop-color="${relationColor(n.relationType)}" stop-opacity="0.07"/></linearGradient>`
);
const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.01.2px
const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
const opVal = nodeOpacity * sp;
const op = opVal < 0.995 ? ` opacity="${opVal.toFixed(2)}"` : '';
parts.push(`<path d="${d}" fill="none" stroke="url(#${gid})" stroke-width="${sw}" stroke-linecap="round"${op}${dashAttr} />`);
}
}
edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
}
function updateReticle() {
// ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0)
let best = Infinity;
for (const n of nodes) {
if (n.hidden) continue;
const d = Math.hypot(camX + n.x * zoom, camY + n.y * zoom);
if (d < best) best = d;
}
reticle.classList.toggle('is-locked', best < 46);
}
function renderAll() {
renderNodes();
renderEdges();
updateReticle();
}
// --- Физика (пружины + отталкивание) ---------------------------------------
@@ -665,11 +669,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return totalV;
}
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»).
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание») + spotlight.
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; // плавное затемнение/прояснение веток
// линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
}
@@ -678,7 +684,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки).
function visualSettling() {
for (const n of nodes) {
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true;
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;
}
return false;
}
@@ -859,7 +866,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
layoutDeep();
renderDeepNodes();
renderEdges();
updateReticle();
schedule();
return;
}
@@ -956,8 +962,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function toggleExpand(node) {
const n = node && (nodeById.get(String(node.id)) || node);
if (!n) return;
n.pinned = !n.pinned;
if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры
// надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером).
const isOpen = n.pinned || (n.expandP || 0) > 0.5;
if (isOpen) {
n.pinned = false; n.hovered = false; // свернуть полностью
haptic(8);
} else {
n.pinned = true; haptic([10, 25, 6, 35, 3]); glideCameraTo(n); // раскрыть + дотяжка камеры
}
wake();
}
// Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
@@ -1078,7 +1090,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
applyWorldTransform();
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
updateReticle();
}
}
@@ -1152,14 +1163,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс
}
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
function pulseReticle() {
reticle.classList.remove('is-pulse');
void reticle.offsetWidth;
reticle.classList.add('is-pulse');
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
}
// Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM,
// чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками.
function syncPositionsFromDOM() {
@@ -1296,7 +1299,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами
cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost)
renderEdges();
pulseReticle();
cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано
wake();
}
@@ -1341,7 +1343,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (ro) ro.disconnect();
edgesSvg.remove();
world.remove();
reticle.remove();
},
};
}
+3 -1
View File
@@ -93,9 +93,11 @@ function addDeepLevels(model) {
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: null, relationType: p.relationType || 'contact',
avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5