Связи (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:
parent
04d9d588e8
commit
72dc83daff
@ -1,2 +1,2 @@
|
||||
client.version=1.2.143
|
||||
server.version=1.2.127
|
||||
client.version=1.2.144
|
||||
server.version=1.2.128
|
||||
|
||||
@ -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 (отдаёт только прямые связи) — требуют доработки сервера.
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user