Связи (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:
Pixel 2026-06-09 22:04:48 +03:00
parent 345a21a211
commit 04d9d588e8
4 changed files with 52 additions and 29 deletions

View File

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

View File

@ -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);

View File

@ -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,

View File

@ -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);