Связи (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
parent 72dc83daff
commit f92e6c3cf1
5 changed files with 63 additions and 90 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.144 client.version=1.2.145
server.version=1.2.128 server.version=1.2.129

View File

@ -61,7 +61,7 @@
(`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными. (`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными.
- **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`, - **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`,
`backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым. `backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым.
- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация - **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`); `box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
хард-лимит ~90 DOM-аватарок (остальное — SVG-точки). хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
@ -94,9 +94,14 @@
втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover` втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover`
`graph.setHover(node|null)`; узел получает флаг `hovered`. `graph.setHover(node|null)`; узел получает флаг `hovered`.
- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка - **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка
остаётся раскрытой и после ухода курсора. Повторный тап снимает фиксацию. остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его
(надёжный toggle: `isOpen = pinned || expandP>0.5`сброс `pinned`+`hovered`).
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс). - Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned` и `hovered` со всех. - **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25`
(узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp).
- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62`
(≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей.
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость.
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов - **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь. 1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко - **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко

View File

@ -44,8 +44,8 @@ const MAX_FULL_NODES = 90; // хард-лимит полных DOM-ават
// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не // «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не
// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep). // участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep).
const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем
const DEEP2_OPACITY = 0.4; // и полупрозрачный const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка»
const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка) const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка)
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
@ -58,6 +58,7 @@ const ZOOM_WHEEL = 0.0016; // чувствительность колеса
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел // Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина). // сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков). // Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр) const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
@ -131,11 +132,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.setAttribute('class', 'fg-edges'); edgesSvg.setAttribute('class', 'fg-edges');
const world = document.createElement('div'); const world = document.createElement('div');
world.className = 'fg-world'; world.className = 'fg-world';
// «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется, stage.append(edgesSvg, world);
// когда под него попадает узел (визуальная зона фокуса при свободном панорамировании).
const reticle = document.createElement('div');
reticle.className = 'fg-reticle';
stage.append(edgesSvg, world, reticle);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование + зум) // Состояние камеры (панорамирование + зум)
@ -154,8 +151,24 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let focusId = ''; let focusId = '';
let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней) let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней)
let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки) 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); }; 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 // Управление циклом rAF
let rafId = 0; let rafId = 0;
let dragging = false; let dragging = false;
@ -264,6 +277,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
hidden: false, hidden: false,
opacity: tier >= 2 ? op : 1, opacity: tier >= 2 ? op : 1,
targetOpacity: op, targetOpacity: op,
spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет)
bloom: false, bloom: false,
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
el, el,
@ -347,7 +361,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.parentId = String(src.parentId || ''); node.parentId = String(src.parentId || '');
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2; node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
node.track = Boolean(src.track); 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.dotOnly = spec.dotOnly;
node.strength = strength; node.strength = strength;
node.relationType = src.relationType; node.relationType = src.relationType;
@ -406,7 +420,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
applyWorldTransform(); applyWorldTransform();
renderEdges(); renderEdges();
updateReticle();
} }
function onWheel(ev) { function onWheel(ev) {
ev.preventDefault(); ev.preventDefault();
@ -419,7 +432,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) { for (const n of nodes) {
n.el.style.transform = n.el.style.transform =
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`; `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' : ''; 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) { for (const n of nodes) {
if (n.tier < 2) continue; 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.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 (под) + тонкий чёткий // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
const shine = Boolean(n.shining) && !n.hidden; 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)}`; 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 по мере разлёта узла // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
// (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра. // (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
@ -568,15 +582,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) { if (n.tier >= 3) {
// 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) { } else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) // 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) { } else if (shine || n.track) {
// glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения» // glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения»
// (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации. // (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации.
const gOp = (0.4 * nodeOpacity).toFixed(2); const cOpVal = nodeOpacity * sp;
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; 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-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`); parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`);
} else { } 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>` + `<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 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} />`); 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('')}`; 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() { function renderAll() {
renderNodes(); renderNodes();
renderEdges(); renderEdges();
updateReticle();
} }
// --- Физика (пружины + отталкивание) --------------------------------------- // --- Физика (пружины + отталкивание) ---------------------------------------
@ -665,11 +669,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return totalV; return totalV;
} }
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»). // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание») + spotlight.
function advanceVisual() { function advanceVisual() {
spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют
for (const n of nodes) { for (const n of nodes) {
n.scale += (n.targetScale - n.scale) * 0.2; n.scale += (n.targetScale - n.scale) * 0.2;
n.opacity += (n.targetOpacity - n.opacity) * 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); 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() { function visualSettling() {
for (const n of nodes) { 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; return false;
} }
@ -859,7 +866,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
layoutDeep(); layoutDeep();
renderDeepNodes(); renderDeepNodes();
renderEdges(); renderEdges();
updateReticle();
schedule(); schedule();
return; return;
} }
@ -956,8 +962,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function toggleExpand(node) { function toggleExpand(node) {
const n = node && (nodeById.get(String(node.id)) || node); const n = node && (nodeById.get(String(node.id)) || node);
if (!n) return; if (!n) return;
n.pinned = !n.pinned; // надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером).
if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры 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(); wake();
} }
// Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец // Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
@ -1078,7 +1090,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
applyWorldTransform(); applyWorldTransform();
advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа
renderEdges(); // рёбра следуют за камерой синхронно (дёшево) renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
updateReticle();
} }
} }
@ -1152,14 +1163,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс 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, // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM,
// чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками. // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками.
function syncPositionsFromDOM() { function syncPositionsFromDOM() {
@ -1296,7 +1299,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами
cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost) cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost)
renderEdges(); renderEdges();
pulseReticle();
cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано
wake(); wake();
} }
@ -1341,7 +1343,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (ro) ro.disconnect(); if (ro) ro.disconnect();
edgesSvg.remove(); edgesSvg.remove();
world.remove(); world.remove();
reticle.remove();
}, },
}; };
} }

View File

@ -93,9 +93,11 @@ function addDeepLevels(model) {
for (let i = 0; i < k2; i += 1) { for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`; const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6; 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({ extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)], 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, strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
}); });
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5 const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5

View File

@ -292,15 +292,15 @@
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */ /* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */
/* 2-й уровень «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени /* 2-й уровень «друзья друзей»: полноценная аватарка (лицо + имя), просто мельче основных.
и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */ Масштаб/прозрачность задаёт движок; здесь читаемый ободок и подпись (не «дырка»). */
.fg-node.is-tier2 .node-dot { .fg-node.is-tier2 .node-dot {
border-color: rgba(150, 180, 220, 0.4); border-color: rgba(170, 200, 240, 0.65);
box-shadow: none; box-shadow: 0 2px 8px rgba(4, 8, 15, 0.4);
} }
.fg-node.is-tier2 .fg-node-label { .fg-node.is-tier2 .fg-node-label {
font-size: 9px; font-size: 9px;
opacity: 0.55; opacity: 0.9;
top: calc(100% + 1px); top: calc(100% + 1px);
} }
@ -337,30 +337,6 @@
color: #efeaff; color: #efeaff;
} }
/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
.fg-reticle {
position: absolute;
left: 50%;
top: 50%;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
border: 2px dashed rgba(150, 190, 255, 0.3);
pointer-events: none;
z-index: 0;
opacity: 0.45;
transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease;
}
.fg-reticle.is-locked {
width: 94px;
height: 94px;
margin: -47px 0 0 -47px;
border-color: rgba(130, 235, 255, 0.65);
opacity: 0.85;
}
/* «Призрак» старой карты при Z-переходе (эффект погружения) */ /* «Призрак» старой карты при Z-переходе (эффект погружения) */
.fg-ghost-layer { .fg-ghost-layer {
position: absolute; position: absolute;
@ -373,17 +349,6 @@
transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease; transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease;
} }
/* Импульс центрального кольца при захвате нового фокуса */
.fg-reticle.is-pulse {
animation: fg-reticle-pulse 0.6s ease;
}
@keyframes fg-reticle-pulse {
0% { transform: scale(1); }
40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); }
100% { transform: scale(1); }
}
/* Панель фильтров слоёв (оверлей под шапкой) */ /* Панель фильтров слоёв (оверлей под шапкой) */
.fg-filter-bar { .fg-filter-bar {
position: absolute; position: absolute;