Связи (pixel-web): этап 2 паутины — hover-превью + collision + zoom + камера-доводчик + синхро-пульс

Доработка режима «Интерактивная паутина» (только лаборатория, deep-режим «Вселенная»):

Взаимодействие (по запросу): наведение ≠ клик.
- Hover-превью: навёл мышь/палец на узел — его ветка ВРЕМЕННО выплывает; убрал — втягивается.
  (pointerover/out для мыши, pointerdown/up для пальца → onNodeHover → graph.setHover; флаг hovered).
- Фиксация кликом: тап/клик → graph.toggleExpand ставит pinned — ветка остаётся раскрытой и
  после ухода курсора; повторный тап снимает фиксацию. Эффект = pinned || hovered (expandTargetOf).

Этап 2 «Мегамасштаб»:
- Collision-расталкивание: раскрытая ветка усиливает отталкивание соседей 1-го уровня
  пропорционально expandP (EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь.
- Свободный зум: колесо мыши (onWheel) + щипок двумя пальцами (activePointers/pinching),
  zoom 0.55–2.6 «к точке»; мир — CSS-scale, линии (SVG) пересчитываются в экранных координатах × zoom.
- Камера-доводчик: при фиксации ветки, если её веер упирается в край, камера мягко дотягивается
  (glideCameraTo → camTargetX/Y, lerp CAM_GLIDE_K в tick); любой жест отменяет доводчик.
- Синхро-пульс: сияющие/трековые «световоды» дышат толщиной/размытием 3.6с в такт ободку узла.

Реальный путь /network-view не затронут: deep-код под tier≥2/hasDeep, hover-колбэк даёт только
лаборатория. Ветка экспериментальная (отдельно от pixel-08.06/PR). Бамп client.version → 1.2.144.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-09 22:34:26 +03:00
parent 04d9d588e8
commit 72dc83daff
5 changed files with 233 additions and 33 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.143
server.version=1.2.127
client.version=1.2.144
server.version=1.2.128

View File

@ -88,6 +88,29 @@
тап по узлам переключает сети.
- Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма).
## Режим «Интерактивная паутина» (ветка `pixel-web`, эксперимент, только лаборатория)
Включается чипом «🌌 Вселенная». Дальние уровни (2-3) по умолчанию скрыты и раскрываются локально:
- **Hover-превью (наведение):** навёл мышь/палец на узел — его ветка временно выплывает; убрал —
втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover`
`graph.setHover(node|null)`; узел получает флаг `hovered`.
- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка
остаётся раскрытой и после ухода курсора. Повторный тап снимает фиксацию.
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned` и `hovered` со всех.
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
дотягивается (`glideCameraTo` → `camTargetX/Y`, lerp `CAM_GLIDE_K` в tick). Любой жест отменяет доводчик.
- **Свободный зум:** колесо мыши (`onWheel`) и щипок двумя пальцами (`activePointers`/`pinching`) —
масштаб `zoom` (0.552.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-`scale`,
линии (отдельный SVG) пересчитываются в экранных координатах (× `zoom`).
- **Синхро-пульс линий:** сияющие/трековые «световоды» (`.fg-edge-glow`/`.fg-edge-core`) «дышат»
толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно).
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
> ⚠️ Эксперимент на ветке `pixel-web` (для отката). Реальный путь `/network-view` не затронут:
> весь deep-код под `tier ≥ 2` / `hasDeep`, hover-колбэк передаёт только лаборатория.
## Ограничения / на будущее
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
упираются в API (отдаёт только прямые связи) — требуют доработки сервера.

View File

@ -50,6 +50,18 @@ const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
// Зум камеры (свободное масштабирование колесом мыши / щипком двумя пальцами): мир целиком
// масштабируется CSS-scale (GPU), линии (отдельный SVG-слой) пересчитываются в экранных координатах.
const ZOOM_MIN = 0.55; // максимальное отдаление
const ZOOM_MAX = 2.6; // максимальное приближение
const ZOOM_WHEEL = 0.0016; // чувствительность колеса мыши
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
const RELATION_COLORS = {
family: 'rgba(255, 159, 94, 0.92)',
friend: 'rgba(120, 179, 255, 0.9)',
@ -113,7 +125,7 @@ function hash01(str) {
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) {
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
@ -126,9 +138,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
stage.append(edgesSvg, world, reticle);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
// Состояние камеры (панорамирование)
// Состояние камеры (панорамирование + зум)
let camX = 0;
let camY = 0;
let zoom = 1; // масштаб камеры (1 = базовый); меняется колесом мыши / щипком
let camTargetX = null; // цель дотяжки камеры-доводчика (null = доводчик выключен)
let camTargetY = null;
let viewW = stage.clientWidth || window.innerWidth;
let viewH = stage.clientHeight || window.innerHeight;
let centerX = viewW / 2;
@ -231,7 +246,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус)
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
expandTarget: 0, // 0/1 — раскрыты ли дочерние глубокие узлы (по клику/ховеру)
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
hovered: false, // временно раскрыто наведением (мышь/палец) — пропадёт при уходе
expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс
dotOnly,
strength,
@ -331,7 +347,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.expandTarget = 0; node.expandP = 0; // при перестроении глубина схлопывается (раскрытие — по клику)
node.pinned = false; node.hovered = false; node.expandP = 0; // при перестроении глубина схлопывается
node.dotOnly = spec.dotOnly;
node.strength = strength;
node.relationType = src.relationType;
@ -355,7 +371,48 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// --- Рендер ----------------------------------------------------------------
function applyWorldTransform() {
world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`;
world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
}
// Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
// в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
function glideCameraTo(n) {
if (!n) return;
const ring = (DEEP_R2 + 64) * zoom; // радиус раскрытого веера + запас (экранные px)
const sx = centerX + camX + n.x * zoom;
const sy = centerY + camY + n.y * zoom;
let tx = camX;
let ty = camY;
const m = CAM_GLIDE_MARGIN;
if (sx - ring < m) tx += (m - (sx - ring));
else if (sx + ring > viewW - m) tx -= ((sx + ring) - (viewW - m));
if (sy - ring < m) ty += (m - (sy - ring));
else if (sy + ring > viewH - m) ty -= ((sy + ring) - (viewH - m));
if (Math.abs(tx - camX) > 1 || Math.abs(ty - camY) > 1) { camTargetX = tx; camTargetY = ty; }
}
// --- Зум камеры (колесо мыши / щипок двумя пальцами) -----------------------
// Масштабируем «к точке» (sx,sy): мировая точка под курсором/центром щипка остаётся на месте.
function setZoom(nextZoom, sx, sy) {
const z0 = zoom;
const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
if (Math.abs(z1 - z0) < 0.0001) return;
const wx = (sx - centerX - camX) / z0;
const wy = (sy - centerY - camY) / z0;
zoom = z1;
camX = sx - centerX - wx * z1;
camY = sy - centerY - wy * z1;
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
applyWorldTransform();
renderEdges();
updateReticle();
}
function onWheel(ev) {
ev.preventDefault();
const rect = stage.getBoundingClientRect();
setZoom(zoom * Math.exp(-ev.deltaY * ZOOM_WHEEL), ev.clientX - rect.left, ev.clientY - rect.top);
wake();
}
function renderNodes() {
@ -393,10 +450,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет.
// Эффективная цель раскрытия узла: раскрыт, если зафиксирован кликом (pinned) ИЛИ временно наведён (hovered).
function expandTargetOf(n) { return (n.pinned || n.hovered) ? 1 : 0; }
function advanceExpand() {
let moving = false;
for (const n of nodes) {
const t = n.expandTarget || 0;
const t = expandTargetOf(n);
const cur = n.expandP || 0;
if (Math.abs(cur - t) > 0.001) {
let next = cur + (t - cur) * 0.18;
@ -424,9 +484,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.innerHTML = '';
return;
}
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость
const tx = (n) => centerX + camX + n.x;
const ty = (n) => centerY + camY + n.y;
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
// Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
const Z = zoom;
const tx = (n) => centerX + camX + n.x * Z;
const ty = (n) => centerY + camY + n.y * Z;
const focusLogin = String(focus.login || '').toLowerCase();
const parts = [];
@ -440,9 +502,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
// начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
const fx = centerX + camX + parent.x;
const fy = centerY + camY + parent.y;
const fr = parent.dotRadius * parent.scale + 4;
const fx = centerX + camX + parent.x * Z;
const fy = centerY + camY + parent.y * Z;
const fr = parent.dotRadius * parent.scale * Z + 4;
const nx = tx(n);
const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
@ -452,14 +514,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
const ex = growing ? (centerX + camX + n.bfx) : nx;
const ey = growing ? (centerY + camY + n.bfy) : ny;
const ex = growing ? (centerX + camX + n.bfx * Z) : nx;
const ey = growing ? (centerY + camY + n.bfy * Z) : ny;
const dx = ex - fx;
const dy = ey - fy;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const nr = n.dotRadius * n.scale + 4;
const nr = n.dotRadius * n.scale * Z + 4;
// концы линии — у краёв кружков
const x1 = fx + ux * fr;
const y1 = fy + uy * fr;
@ -540,7 +602,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let best = Infinity;
for (const n of nodes) {
if (n.hidden) continue;
const d = Math.hypot(camX + n.x, camY + n.y);
const d = Math.hypot(camX + n.x * zoom, camY + n.y * zoom);
if (d < best) best = d;
}
reticle.classList.toggle('is-locked', best < 46);
@ -585,7 +647,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let dist2 = dx * dx + dy * dy;
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
const dist = Math.sqrt(dist2);
const f = chargeNow / dist2;
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
const ex = Math.max(n.expandP || 0, m.expandP || 0);
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
ax += (dx / dist) * f;
ay += (dy / dist) * f;
}
@ -822,6 +887,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); }
else if (pbMag < 12) panTwang = false;
// камера-доводчик: плавно дотягиваем камеру к цели (после раскрытия ветки). Жест уже обнулил цель.
let camGliding = false;
if (camTargetX !== null && !dragging && !panActive) {
camX += (camTargetX - camX) * CAM_GLIDE_K;
camY += (camTargetY - camY) * CAM_GLIDE_K;
if (Math.abs(camTargetX - camX) < 0.5 && Math.abs(camTargetY - camY) < 0.5) {
camX = camTargetX; camY = camTargetY; camTargetX = null; camTargetY = null;
} else { camGliding = true; }
applyWorldTransform();
}
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
@ -841,7 +917,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
renderAll();
const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
if (tween || dragging || panActive || camGliding || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
schedule();
} else {
freezeGraph(); // система успокоилась — замираем
@ -866,22 +942,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let downNodeEl = null;
let longTimer = 0;
let longFired = false;
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
let pinching = false; // активен щипок (pan/tap на это время заморожены)
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
// Режим «Интерактивная паутина»: тап по узлу РАСКРЫВАЕТ/СВОРАЧИВАЕТ его ветку НА МЕСТЕ (без смены
// центра). expandTarget 0↔1, плавная анимация expandP (см. advanceExpand/layoutDeep).
// Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см.
// advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение).
// Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся
// раскрытой, даже когда убрали палец/мышь (в отличие от ховера). + камера-доводчик подводит кластер в кадр.
function toggleExpand(node) {
const n = node && (nodeById.get(String(node.id)) || node);
if (!n) return;
n.expandTarget = n.expandTarget ? 0 : 1;
if (n.expandTarget) haptic([10, 25, 6, 35, 3]); // «выброс вселенной» — серия затухающих импульсов
n.pinned = !n.pinned;
if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры
wake();
}
// Глобальный сброс: рекурсивно сворачиваем ВСЕ раскрытые ветки (тап по корню — Ивану).
// Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
// над узлом, и втягивается при уходе (если узел не закреплён кликом). node=null — снять ховер со всех.
function setHover(node) {
const target = node && (nodeById.get(String(node.id)) || node);
let changed = false;
for (const n of nodes) {
const want = n === target;
if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; }
}
if (changed) wake();
}
// Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток (тап по корню — Ивану).
function collapseAll() {
let any = false;
for (const n of nodes) { if (n.expandTarget) { n.expandTarget = 0; any = true; } }
for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } }
if (any) haptic(14);
wake();
}
@ -894,10 +987,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
function onPointerDown(ev) {
activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
// второй палец → режим щипкового зума: прерываем pan/tap/longpress, фиксируем базовую дистанцию
if (activePointers.size === 2) {
pinching = true;
camTargetX = null; camTargetY = null;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) { downNodeEl.classList.remove('is-pressed'); }
dragging = false;
const pts = [...activePointers.values()];
pinchDist0 = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
return;
}
if (pointerId !== null) return;
pointerId = ev.pointerId;
panVelX = 0; // новое касание мгновенно прерывает инерцию
panVelY = 0;
camTargetX = null; camTargetY = null; // касание отменяет доводчик камеры
try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ }
downX = ev.clientX;
downY = ev.clientY;
@ -908,6 +1014,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
const downNode = nodeFromEvent(ev);
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
if (downNode && typeof onNodeLongPress === 'function') {
longTimer = window.setTimeout(() => {
if (moved) return;
@ -919,7 +1027,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
}
// Ховер мышью: наведение на узел → превью ветки; уход с узла → сворачивание (если не закреплён кликом).
function onPointerOver(ev) {
if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
const node = el ? nodes.find((n) => String(n.id) === String(el.dataset.nodeId)) : null;
if (node && node !== hoverNode) { hoverNode = node; onNodeHover(node, true); }
}
function onPointerOut(ev) {
if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
const toEl = ev.relatedTarget instanceof Element ? ev.relatedTarget.closest('.fg-node') : null;
if (toEl && hoverNode && String(toEl.dataset.nodeId) === String(hoverNode.id)) return; // ещё внутри узла
if (hoverNode) { hoverNode = null; onNodeHover(null, false); }
}
function onPointerMove(ev) {
if (activePointers.has(ev.pointerId)) activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
// щипок двумя пальцами: масштабируем относительно центра между пальцами (зум «к точке»)
if (pinching && activePointers.size >= 2) {
const pts = [...activePointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
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;
setZoom(zoom * (dist / pinchDist0), mx, my);
pinchDist0 = dist;
wake();
return;
}
if (ev.pointerId !== pointerId) return;
const dx = ev.clientX - downX;
const dy = ev.clientY - downY;
@ -927,6 +1062,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
moved = true;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
cancelTween(); // жест прерывает анимацию центрирования
dragging = true;
}
@ -945,6 +1083,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
function onPointerUp(ev) {
activePointers.delete(ev.pointerId);
// выход из щипка: пока пальцев <2 — щипок завершён; остаток НЕ превращаем в pan/tap (избегаем рывка)
if (pinching) {
if (activePointers.size < 2) { pinching = false; pinchDist0 = 0; }
if (ev.pointerId === pointerId) { pointerId = null; dragging = false; }
return;
}
if (ev.pointerId !== pointerId) return;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
@ -953,6 +1098,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const wasLong = longFired;
pointerId = null;
dragging = false;
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
if (wasMoved || wasLong) {
// после pan даём физике чуть устаканиться и уснуть
@ -1020,8 +1167,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const n of nodes) {
const dot = n.el.querySelector('.node-dot') || n.el;
const r = dot.getBoundingClientRect();
n.x = (r.left + r.width / 2) - sr.left - centerX - camX;
n.y = (r.top + r.height / 2) - sr.top - centerY - camY;
n.x = ((r.left + r.width / 2) - sr.left - centerX - camX) / zoom;
n.y = ((r.top + r.height / 2) - sr.top - centerY - camY) / zoom;
// живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой
const o = parseFloat(getComputedStyle(n.el).opacity);
if (Number.isFinite(o)) n.opacity = o;
@ -1158,6 +1305,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
stage.addEventListener('pointermove', onPointerMove);
stage.addEventListener('pointerup', onPointerUp);
stage.addEventListener('pointercancel', onPointerUp);
stage.addEventListener('pointerover', onPointerOver); // ховер мышью → превью ветки
stage.addEventListener('pointerout', onPointerOut);
stage.addEventListener('wheel', onWheel, { passive: false }); // зум колесом мыши
window.addEventListener('resize', onResize);
let ro = null;
@ -1172,7 +1322,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
recenter: (id) => startRecenterTween(id),
setModel,
setFilter,
toggleExpand, // mind-map: раскрыть/свернуть ветку узла на месте
toggleExpand, // mind-map: ЗАФИКСИРОВАТЬ/снять раскрытие ветки кликом (pinned)
setHover, // mind-map: ВРЕМЕННОЕ раскрытие ветки наведением (node | null)
collapseAll, // mind-map: свернуть все ветки (тап по корню)
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
destroy() {
@ -1183,6 +1334,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
stage.removeEventListener('pointermove', onPointerMove);
stage.removeEventListener('pointerup', onPointerUp);
stage.removeEventListener('pointercancel', onPointerUp);
stage.removeEventListener('pointerover', onPointerOver);
stage.removeEventListener('pointerout', onPointerOut);
stage.removeEventListener('wheel', onWheel);
window.removeEventListener('resize', onResize);
if (ro) ro.disconnect();
edgesSvg.remove();

View File

@ -46,9 +46,10 @@ function helpText() {
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — прототип глубины 2-3 уровней (фейковые связи, для наглядности).',
' По умолчанию дальние связи скрыты. Зажми/наведи узел — его микро-связи выплывают',
' вокруг, отпусти — втягиваются обратно. Перейдёшь на узел — путь до него горит треком.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' По умолчанию дальние связи скрыты. Наведи мышь/палец на узел — его микро-связи временно',
' выплывают (превью), убери — втягиваются. Кликни/тапни узел — раскрытие ФИКСИРУЕТСЯ.',
' Тап по центру (Ивану) — свернуть все ветки. Колесо мыши / щипок — зум. Свайп — pan.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
@ -189,7 +190,8 @@ export function renderNetworkLab({ navigate }) {
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
if (deepMode) {
// режим «Интерактивная паутина»: НЕ меняем центр — раскрываем/сворачиваем ветку узла на месте
// режим «Интерактивная паутина»: НЕ меняем центр — клик ФИКСИРУЕТ раскрытие ветки (остаётся
// открытой и после ухода курсора); повторный клик снимает фиксацию.
graph.toggleExpand(node);
return;
}
@ -199,6 +201,9 @@ export function renderNetworkLab({ navigate }) {
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
},
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
onCenterTap: (node) => {
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
if (deepMode) { graph.collapseAll(); return; }

View File

@ -68,12 +68,30 @@
stroke-width: 4;
stroke-linecap: round;
filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
/* синхро-пульс: нить «дышит» толщиной/размытием в том же ритме (3.6с), что и ободок сияющего узла
в покое SVG не перерисовывается, поэтому все нити стартуют синхронно и пульсируют вместе. */
animation: fg-edge-pulse 3.6s ease-in-out infinite;
}
.fg-edge-core {
fill: none;
stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
stroke-width: 1.5;
stroke-linecap: round;
animation: fg-edge-core-pulse 3.6s ease-in-out infinite;
}
/* пульс «световода» в такт дыханию сияющего ободка (та же длительность 3.6с) */
@keyframes fg-edge-pulse {
0%, 100% { stroke-width: 3.4; filter: blur(1.6px); }
50% { stroke-width: 5.2; filter: blur(2.8px); }
}
@keyframes fg-edge-core-pulse {
0%, 100% { stroke-width: 1.3; }
50% { stroke-width: 1.9; }
}
@media (prefers-reduced-motion: reduce) {
.fg-edge-glow, .fg-edge-core { animation: none; }
}
.fg-node {