Связи (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:
parent
345a21a211
commit
04d9d588e8
@ -1,2 +1,2 @@
|
||||
client.version=1.2.142
|
||||
client.version=1.2.143
|
||||
server.version=1.2.127
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -188,12 +188,22 @@ export function renderNetworkLab({ navigate }) {
|
||||
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
|
||||
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
||||
onNodeTap: (node) => {
|
||||
const from = centerLogin; // предыдущий фокус → трек прохождения
|
||||
if (deepMode) {
|
||||
// режим «Интерактивная паутина»: НЕ меняем центр — раскрываем/сворачиваем ветку узла на месте
|
||||
graph.toggleExpand(node);
|
||||
return;
|
||||
}
|
||||
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
|
||||
const from = centerLogin;
|
||||
centerLogin = node.login || node.id;
|
||||
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||
},
|
||||
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
|
||||
onCenterTap: (node) => {
|
||||
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
||||
if (deepMode) { graph.collapseAll(); return; }
|
||||
window.alert(`Профиль: ${node.name || node.login || node.id}`);
|
||||
},
|
||||
onNodeLongPress: (node, point) => openNodeMenu({
|
||||
login: node.name || node.login || node.id,
|
||||
relationType: node.relationType,
|
||||
|
||||
@ -293,12 +293,24 @@
|
||||
border: 0;
|
||||
background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%);
|
||||
box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5);
|
||||
/* медленное мерцание «звезды» — по box-shadow/яркости (НЕ opacity/scale: ими управляет движок при
|
||||
раскрытии). У каждой звезды своя задержка (inline animation-delay) → живое созвездие. */
|
||||
animation: fg-star-twinkle 3.4s ease-in-out infinite;
|
||||
}
|
||||
.fg-dot.is-tier3.is-shine {
|
||||
background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%);
|
||||
box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7);
|
||||
}
|
||||
|
||||
@keyframes fg-star-twinkle {
|
||||
0%, 100% { box-shadow: 0 0 3px rgba(150, 220, 255, 0.45), 0 0 7px rgba(115, 200, 255, 0.25); filter: brightness(0.78); }
|
||||
50% { box-shadow: 0 0 7px rgba(165, 235, 255, 0.95), 0 0 15px rgba(120, 210, 255, 0.6); filter: brightness(1.3); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fg-dot.is-tier3 { animation: none; }
|
||||
}
|
||||
|
||||
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
|
||||
.fg-deep-chip.is-active {
|
||||
background: rgba(150, 130, 255, 0.18);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user