SHA256
Связи (pixel-web): режим «Интерактивная паутина» — раскрытие веток без смены центра
Этап 1 mind-map (только лаборатория, deep-режим «Вселенная»): - Отмена прыжков в центр: тап по периферийному узлу больше НЕ перецентрирует — он остаётся на орбите, а из него раскрывается/сворачивается (toggle) его ветка дальних связей НА МЕСТЕ. - Глобальный сброс: тап по корню (Иван) рекурсивно сворачивает все раскрытые ветки (collapseAll). - Глубина скрыта по умолчанию; ветка плавно выплывает (expandP, ~400мс) и втягивается по повтору. - Мерцающие звёзды 3-го уровня (CSS box-shadow/brightness, десинхрон по узлам) — «созвездие». - Тактильный отклик navigator.vibrate(): клик при нажатии, серия импульсов на bloom-раскрытие, щелчок «гитарной струны» при сильном натяжении нитей свайпом. - Движок: API toggleExpand/collapseAll; убрана press/hover-логика раскрытия (заменена тапом). Ветка экспериментальная (отдельно от pixel-08.06/PR), бамп client.version → 1.2.143. Ещё не сделано (следующие этапы): collision-расталкивание веток, камера-доводчик, zoom, синхро-пульс линий к сияющим. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// возвращается к нулю при отпускании (lerp). Им смещаем контрольные точки Безье — нити тянутся.
|
||||
let panBendX = 0;
|
||||
let panBendY = 0;
|
||||
let panTwang = false; // флаг «гитарной струны»: один вибро-щелчок при сильном натяжении нитей
|
||||
function advancePanBend() {
|
||||
panBendX += (panVelX - panBendX) * 0.3;
|
||||
panBendY += (panVelY - panBendY) * 0.3;
|
||||
@@ -281,12 +282,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
if (dotOnly) {
|
||||
el.className = [
|
||||
'fg-node', 'fg-dot',
|
||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся точка)
|
||||
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
|
||||
src.shining ? 'is-shine' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
].filter(Boolean).join(' ');
|
||||
el.dataset.nodeId = String(src.id);
|
||||
el.title = src.name || src.login || '';
|
||||
// десинхронизируем мерцание звёзд (отрицательная задержка) → живое «созвездие», не «моргание в такт»
|
||||
if (tier >= 3) el.style.animationDelay = `${(-hash01(`${src.id}~t`) * 3.4).toFixed(2)}s`;
|
||||
return el;
|
||||
}
|
||||
el.className = [
|
||||
@@ -814,6 +817,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
panVelY = 0;
|
||||
}
|
||||
advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю
|
||||
// «гитарная струна»: один короткий вибро-щелчок при сильном натяжении нитей свайпом
|
||||
const pbMag = Math.abs(panBendX) + Math.abs(panBendY);
|
||||
if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); }
|
||||
else if (pbMag < 12) panTwang = false;
|
||||
|
||||
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
||||
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
||||
@@ -859,25 +866,24 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
let downNodeEl = null;
|
||||
let longTimer = 0;
|
||||
let longFired = false;
|
||||
let pressedNode = null; // узел, чьи глубокие дети сейчас «выплыли» (по нажатию)
|
||||
let hoverNode = null; // узел под курсором (десктоп) — тоже раскрывает свои глубокие связи
|
||||
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
|
||||
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
|
||||
|
||||
// раскрыть/схлопнуть глубокие уровни вокруг узла (локальный bloom по взаимодействию)
|
||||
function expandNode(n) { if (n && !n.expandTarget) { n.expandTarget = 1; wake(); } }
|
||||
function collapseNode(n) { if (n && n.expandTarget) { n.expandTarget = 0; wake(); } }
|
||||
|
||||
// Hover (десктоп): наведение раскрывает глубокие связи узла, увод/смена — схлопывает.
|
||||
function onHoverMove(ev) {
|
||||
if (pointerId !== null || dragging) return; // только когда не нажато и не тащим
|
||||
const n = nodeFromEvent(ev);
|
||||
if (n === hoverNode) return;
|
||||
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
|
||||
hoverNode = n;
|
||||
if (n) expandNode(n);
|
||||
// Режим «Интерактивная паутина»: тап по узлу РАСКРЫВАЕТ/СВОРАЧИВАЕТ его ветку НА МЕСТЕ (без смены
|
||||
// центра). expandTarget 0↔1, плавная анимация expandP (см. advanceExpand/layoutDeep).
|
||||
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]); // «выброс вселенной» — серия затухающих импульсов
|
||||
wake();
|
||||
}
|
||||
function onHoverLeave() {
|
||||
if (hoverNode && hoverNode !== pressedNode) collapseNode(hoverNode);
|
||||
hoverNode = null;
|
||||
// Глобальный сброс: рекурсивно сворачиваем ВСЕ раскрытые ветки (тап по корню — Ивану).
|
||||
function collapseAll() {
|
||||
let any = false;
|
||||
for (const n of nodes) { if (n.expandTarget) { n.expandTarget = 0; any = true; } }
|
||||
if (any) haptic(14);
|
||||
wake();
|
||||
}
|
||||
|
||||
function nodeFromEvent(ev) {
|
||||
@@ -900,9 +906,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
moved = false;
|
||||
longFired = false;
|
||||
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||
if (downNodeEl) downNodeEl.classList.add('is-pressed'); // тактильный отклик «нажатия вглубь»
|
||||
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
||||
const downNode = nodeFromEvent(ev);
|
||||
if (downNode) { pressedNode = downNode; expandNode(downNode); } // локальный bloom его глубоких связей
|
||||
if (downNode && typeof onNodeLongPress === 'function') {
|
||||
longTimer = window.setTimeout(() => {
|
||||
if (moved) return;
|
||||
@@ -922,7 +927,6 @@ 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 (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // свайп — схлопываем глубину
|
||||
cancelTween(); // жест прерывает анимацию центрирования
|
||||
dragging = true;
|
||||
}
|
||||
@@ -944,7 +948,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
|
||||
if (pressedNode) { collapseNode(pressedNode); pressedNode = null; } // отпустили — глубина уходит обратно
|
||||
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
|
||||
const wasMoved = moved;
|
||||
const wasLong = longFired;
|
||||
@@ -1153,8 +1156,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
|
||||
stage.addEventListener('pointerdown', onPointerDown);
|
||||
stage.addEventListener('pointermove', onPointerMove);
|
||||
stage.addEventListener('pointermove', onHoverMove); // hover-раскрытие глубины (десктоп)
|
||||
stage.addEventListener('pointerleave', onHoverLeave);
|
||||
stage.addEventListener('pointerup', onPointerUp);
|
||||
stage.addEventListener('pointercancel', onPointerUp);
|
||||
window.addEventListener('resize', onResize);
|
||||
@@ -1171,6 +1172,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
recenter: (id) => startRecenterTween(id),
|
||||
setModel,
|
||||
setFilter,
|
||||
toggleExpand, // mind-map: раскрыть/свернуть ветку узла на месте
|
||||
collapseAll, // mind-map: свернуть все ветки (тап по корню)
|
||||
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
|
||||
destroy() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
@@ -1178,8 +1181,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
if (longTimer) window.clearTimeout(longTimer);
|
||||
stage.removeEventListener('pointerdown', onPointerDown);
|
||||
stage.removeEventListener('pointermove', onPointerMove);
|
||||
stage.removeEventListener('pointermove', onHoverMove);
|
||||
stage.removeEventListener('pointerleave', onHoverLeave);
|
||||
stage.removeEventListener('pointerup', onPointerUp);
|
||||
stage.removeEventListener('pointercancel', onPointerUp);
|
||||
window.removeEventListener('resize', onResize);
|
||||
|
||||
Reference in New Issue
Block a user