From 04d9d588e80a11c6de1bc68224b7d20fb808dde67352a596560a78531b078332 Mon Sep 17 00:00:00 2001 From: Pixel Date: Tue, 9 Jun 2026 22:04:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-web):?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=C2=AB=D0=98=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=83=D1=82=D0=B8=D0=BD=D0=B0=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=BA=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=86=D0=B5=D0=BD=D1=82=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 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) --- VERSION.properties | 2 +- shine-UI/js/pages/network/force-graph.js | 53 ++++++++++++------------ shine-UI/js/pages/network/lab.js | 14 ++++++- shine-UI/styles/network-graph.css | 12 ++++++ 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 405cd97..0db986a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.142 +client.version=1.2.143 server.version=1.2.127 diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index ea0de4b..d08b39f 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -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); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 8f2e64d..0641a59 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -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, diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 5dab4ac..00bd43d 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -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);