From 557ea96be027870b0ecc1735f7b789d68d89c3d1426555a31f1df3215c03e52c Mon Sep 17 00:00:00 2001 From: Pixel Date: Wed, 10 Jun 2026 00:43:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-aquariu?= =?UTF-8?q?m,=2010.06):=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D1=8F=202=20(UI-?= =?UTF-8?q?=D1=84=D0=B8=D1=88=D0=BA=D0=B8)=20=E2=80=94=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA,=20=D1=85=D0=BB=D0=B5=D0=B1=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BA=D1=80=D0=BE=D1=88=D0=BA=D0=B8,=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B9=D0=B4=D0=B6=D0=B8,=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Всё в лаборатории (вариант 2: реальный путь /network-view не трогаем). - Поиск + телепорт: строка .fg-search; Enter → graph.findNode(имя) → камера летит к узлу (dive в «Вселенной», иначе перецентр). - Хлебные крошки: .fg-breadcrumb «Иван › Нина › Ада» (движок шлёт onDiveChange(path), API getDivePath); клик по корню — полный сброс, по предку — навигация на его уровень. - Бейдж числа связей: .fg-node-badge (degreeById → updateBadges; у центра — число связей 1-го уровня). - Цветовые кластеры: мягкая аура узла по типу связи (CSS is-family/friend/business/contact). Автопроверки расширены до 17 ассертов (добавлены поиск/крошки/бейдж) — прогон 17/17 PASS. Фикс: TDZ breadcrumbEl (объявлен до createForceGraph, т.к. onDiveChange вызывается при монтировании). Бамп client.version → 1.2.149. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION.properties | 4 +- .../features/interactive-network-graph.md | 14 ++- shine-UI/js/pages/network/force-graph.js | 58 +++++++++- shine-UI/js/pages/network/lab.js | 58 ++++++++++ shine-UI/js/pages/network/selftest.js | 21 ++++ shine-UI/styles/network-graph.css | 106 ++++++++++++++++++ 6 files changed, 254 insertions(+), 7 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 30e3fe1..ccc36c5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.148 -server.version=1.2.132 +client.version=1.2.149 +server.version=1.2.133 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 28481ff..9cb4e24 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -137,10 +137,18 @@ мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход; **префетч аватарок** детей при наведении/нырке. +**Фишки (партия 2, лаборатория):** +- **Поиск + телепорт** — строка `.fg-search`; Enter → `graph.findNode(имя)` → камера летит к узлу (dive в + «Вселенной», иначе перецентр). +- **Хлебные крошки** — `.fg-breadcrumb` «Иван › Нина › Ада» (движок шлёт `onDiveChange(path)`, + API `getDivePath()`); клик по корню — полный сброс, по предку — навигация на тот уровень. +- **Бейдж числа связей** — `.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`). +- **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`). + **Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`, -прогоняет 14 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/выход) через детерминированные -dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры до покоя — не -зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны. +прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через +детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры +до покоя — не зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны. > ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката. > Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2 diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index fed2958..e9786db 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -143,7 +143,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, onNodeHover } = {}) { +export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) { // Слои DOM const edgesSvg = document.createElementNS(SVGNS, 'svg'); edgesSvg.setAttribute('class', 'fg-edges'); @@ -173,18 +173,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let diveZoom = 1; // целевой зум активного погружения let surfacing = false; // идёт «всплытие» назад (камера/зум возвращаются к корню) let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания) + let degreeById = new Map(); // id → число связей узла (для бейджа-счётчика на аватарке) const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); // число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу») childCountByParent = new Map(); + degreeById = new Map(); + let tier1count = 0; for (const n of nodes) { if (n.tier >= 2 && n.parentId) { const i = childCountByParent.get(n.parentId) || 0; n.sibIndex = i; childCountByParent.set(n.parentId, i + 1); + degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь + } else if (n.tier === 1 && String(n.id) !== focusId) { + tier1count += 1; } } + degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня }; // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы @@ -212,12 +219,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return set; } let _pathSet = new Set(); - let _pathSetKey = ''; + let _pathSetKey = ''; function ensurePathSet() { const k = diveTargetId || ''; if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); } return _pathSet; } + // Хлебные крошки: упорядоченный путь focus → … → нырнутый узел (для UI-навигации). Пусто = верхний уровень. + function divePathNodes() { + const out = []; + if (!diveTargetId) return out; + let cur = nodeById.get(diveTargetId); let guard = 0; + while (cur && guard++ < 16) { out.push(cur); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; } + const f = nodeById.get(focusId); + if (f && out[out.length - 1] !== f) out.push(f); + return out.reverse(); // от корня (Иван) к цели + } + function emitDiveChange() { + if (typeof onDiveChange !== 'function') return; + onDiveChange(divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus }))); + } + // Поиск узла по имени/логину (строка поиска): точное совпадение приоритетнее подстроки. + function findNode(query) { + const q = String(query || '').trim().toLowerCase(); + if (!q) return null; + let hit = nodes.find((n) => String(n.name || '').toLowerCase() === q || String(n.login || '').toLowerCase() === q); + if (!hit) hit = nodes.find((n) => String(n.name || '').toLowerCase().includes(q) || String(n.login || '').toLowerCase().includes(q)); + return hit ? { id: String(hit.id), name: hit.name || hit.login || String(hit.id), tier: hit.tier } : null; + } // Базовый масштаб узла по его роли/уровню (как в makeNodeState) — чтобы привести героя и его детей // к ОДИНАКОВОМУ видимому размеру независимо от tier (depthScale = желаемый_видимый / базовый). function baseScaleOf(n) { @@ -419,6 +448,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL }); el.append(avatar); + // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. + const badge = document.createElement('span'); + badge.className = 'fg-node-badge'; + badge.hidden = true; + el.append(badge); + const label = document.createElement('span'); label.className = 'fg-node-label'; label.textContent = src.name || src.login || ''; @@ -426,6 +461,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return el; } + // Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex. + function updateBadges() { + for (const n of nodes) { + const badge = n.el.querySelector('.fg-node-badge'); + if (!badge) continue; // у точек (dotOnly) бейджа нет + const deg = degreeById.get(String(n.id)) || 0; + if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; } + else { badge.hidden = true; } + } + } + // Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель. function updateNodeRole(node, spec) { const src = spec.src; @@ -611,6 +657,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.lod = full ? 'full' : 'dot'; n.dotOnly = !full; n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей + if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } } } function renderEdges() { @@ -1158,6 +1205,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } } if (diveTargetId) { diveTargetId = null; surfacing = true; any = true; } // всплыть наверх if (any) haptic(14); + emitDiveChange(); // крошки → верхний уровень wake(); } @@ -1178,6 +1226,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL camTargetX = null; camTargetY = null; // dive-камера центрирует сама prefetchChildren(n); // подгружаем лица детей заранее haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс + emitDiveChange(); // обновляем хлебные крошки (Иван › … › цель) wake(); } // Всплытие/закрытие ветки: ПОЛНЫЙ сброс — снимаем все фиксации/ховеры (дети втягиваются), @@ -1187,6 +1236,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL diveTargetId = null; surfacing = true; haptic(10); + emitDiveChange(); // крошки → верхний уровень wake(); } @@ -1489,8 +1539,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL pendingFocusOrigin = null; diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов + updateBadges(); // бейджи-счётчики связей под новый набор layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает) + emitDiveChange(); // сбрасываем хлебные крошки (новый граф = верхний уровень) camX = 0; camY = 0; applyWorldTransform(); @@ -1538,6 +1590,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL diveTo, // Smart Zoom: погрузиться в узел 2-го+ уровня (наезд камеры, «аквариум») exitDive, // Smart Zoom: всплыть из погружения на уровень назад collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню) + findNode, // поиск узла по имени/логину → { id, name, tier } | null (для строки поиска) + getDivePath: () => divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })), // хлебные крошки getFocusNode: () => nodes.find((n) => n.isFocus) || null, // --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) ------------------- // Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера. diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 43d7899..9b53dd3 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -189,6 +189,9 @@ export function renderNetworkLab({ navigate }) { const model = buildLabModel(centerLogin, deepMode); + // Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы. + let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже) + // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. const graph = createForceGraph({ @@ -212,6 +215,8 @@ export function renderNetworkLab({ navigate }) { // Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором, // втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная». onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); }, + // Изменение пути погружения → перерисовываем хлебные крошки (Иван › Нина › Ада). + onDiveChange: (path) => renderBreadcrumb(path), onCenterTap: (node) => { // в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки if (deepMode) { graph.collapseAll(); return; } @@ -256,6 +261,59 @@ export function renderNetworkLab({ navigate }) { filterBar.append(deepChip); stage.append(filterBar); + // --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) --- + const searchWrap = document.createElement('div'); + searchWrap.className = 'fg-search'; + searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation()); + const searchIco = document.createElement('span'); + searchIco.className = 'fg-search-ico'; + searchIco.textContent = '🔍'; + const searchInput = document.createElement('input'); + searchInput.type = 'search'; + searchInput.placeholder = 'Найти человека…'; + searchInput.setAttribute('aria-label', 'Поиск по имени'); + function doSearch() { + const hit = graph.findNode(searchInput.value); + if (!hit) return; + if (deepMode) { + graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного + } else { + const from = centerLogin; + centerLogin = hit.id; + graph.setModel(buildLabModel(centerLogin, deepMode, from)); + if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); + } + searchInput.blur(); + } + searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); + searchWrap.append(searchIco, searchInput); + stage.append(searchWrap); + + // --- Хлебные крошки: стек погружений (Иван › Нина › Ада); клик по крошке — навигация назад --- + breadcrumbEl = document.createElement('div'); + breadcrumbEl.className = 'fg-breadcrumb'; + breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation()); + stage.append(breadcrumbEl); + // hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI) + function renderBreadcrumb(path) { + if (!breadcrumbEl) return; + breadcrumbEl.innerHTML = ''; + const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении + breadcrumbEl.classList.toggle('is-open', open); + if (!open) return; + path.forEach((p, i) => { + if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = '›'; breadcrumbEl.append(sep); } + const c = document.createElement('button'); + c.type = 'button'; + c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`; + c.textContent = p.name; + if (i < path.length - 1) { + c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); }); + } + breadcrumbEl.append(c); + }); + } + // Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль). if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) { window.__fg = graph; diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js index 13aeabe..054520b 100644 --- a/shine-UI/js/pages/network/selftest.js +++ b/shine-UI/js/pages/network/selftest.js @@ -81,6 +81,27 @@ export async function runNetworkSelfTest(graph, deepChipEl) { check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`); } + // === Тест F: поиск по имени находит узел (для строки поиска + телепорта) === + const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId); + if (named && typeof graph.findNode === 'function') { + const byId = graph.findNode(named.id); + check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`); + } + + // === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) === + if (typeof graph.getDivePath === 'function' && t2withKids) { + const path = graph.getDivePath(); + const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id; + check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`); + } + + // === Тест H: бейдж числа связей виден и числовой (DOM) === + if (typeof document !== 'undefined') { + const fb = document.querySelector('.fg-node.is-focus .fg-node-badge'); + const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0; + check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`); + } + // === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает === graph.exitDive(); graph.pumpForTest(); diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 5ce08d6..afd3869 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -533,3 +533,109 @@ .fg-sheet-actions > button { flex: 1; } + +/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ + +/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */ +.fg-node-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: rgba(16, 24, 40, 0.92); + border: 1px solid rgba(150, 200, 255, 0.5); + color: #d9ecff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + pointer-events: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); +} +.fg-node.is-focus .fg-node-badge { + background: rgba(61, 196, 223, 0.95); + border-color: rgba(220, 245, 255, 0.8); + color: #06131c; +} +.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } + +/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */ +.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); } +.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); } +.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); } +.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); } +/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */ +.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } + +/* Строка поиска (оверлей вверху, под панелью фильтров) */ +.fg-search { + position: absolute; + top: max(92px, calc(env(safe-area-inset-top) + 88px)); + left: 50%; + transform: translateX(-50%); + z-index: 12; + width: min(280px, 70vw); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 0.5px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08); +} +.fg-search input { + flex: 1; + border: 0; + background: transparent; + color: #eaf2ff; + font-size: 13px; + outline: none; +} +.fg-search input::placeholder { color: #7d8aa6; } +.fg-search .fg-search-ico { color: #9fc0ff; font-size: 13px; } + +/* Хлебные крошки навигации (стек погружений: Иван › Нина › Ада) */ +.fg-breadcrumb { + position: absolute; + top: max(132px, calc(env(safe-area-inset-top) + 128px)); + left: 0; + right: 0; + z-index: 12; + display: none; + justify-content: center; + flex-wrap: wrap; + gap: 4px; + padding: 0 12px; + pointer-events: none; +} +.fg-breadcrumb.is-open { display: flex; } +.fg-crumb { + pointer-events: auto; + border: 0; + background: rgba(16, 24, 40, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: #cfe0ff; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: 5px 10px; + border-radius: 999px; + cursor: pointer; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.fg-crumb.is-last { + background: rgba(125, 215, 255, 0.18); + color: #eaf7ff; + cursor: default; +} +.fg-crumb-sep { color: #5f7196; font-size: 11px; align-self: center; pointer-events: none; }