diff --git a/VERSION.properties b/VERSION.properties index 0db986a..be04c84 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.143 -server.version=1.2.127 +client.version=1.2.144 +server.version=1.2.128 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 972a472..419ca65 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -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.55–2.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 (отдаёт только прямые связи) — требуют доработки сервера. diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index d08b39f..eca2f91 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -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(); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 0641a59..2f7b0ba 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -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; } diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 00bd43d..82fff1c 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -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 {