diff --git a/VERSION.properties b/VERSION.properties index fe2f1be..e03abab 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.136 +client.version=1.2.137 server.version=1.2.127 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 91601d6..85e7cb9 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -25,26 +25,38 @@ ## Ключевые механики - **Diffing-переходы (непрерывность состояний):** при смене фокуса общие узлы (тот же `id`) не - пересоздаются, а перелетают пружиной на новые места; новые «расцветают» (bloom) каскадом из центра; + пересоздаются, а перелетают на новые места; новые «расцветают» (bloom) каскадом из центра; исчезнувшие уходят в Ghost-слой. -- **Ghost-слой:** снимок всего старого графа (узлы + линии) на полноэкранном overlay, застывает - на месте, `scale 1→0.7` + `opacity 0.5→0` за **800мс**, затем удаляется (красивый шлейф истории). -- **Физика:** мягкая радиальная пружина к орбите + взаимное отталкивание (charge) → органичная, - слегка неровная орбита; фокус влетает в центр упруго. Координаты узлов на трансформах (GPU). -- **Каскадный bloom:** новые узлы скрыты в центре и «выстреливают» по очереди (`order × 40мс`). -- **Динамическая вязкость:** первые ~600мс после перестроения трение завышено (0.92), отталкивание - ослаблено (×0.45) → гасит «взрыв», затем плавно к базе (0.82) — мягкое «резиновое» появление. +- **CSS-bloom (разлёт без тряски):** разлёт/перелёт узлов делают нативные CSS-переходы на + `transform` (компоновщик, `cubic-bezier(0.16,1,0.3,1)`, `BLOOM_MS` со ступенчатой задержкой + `order × 40мс`), а НЕ JS-физика. Работает даже при троттлинге rAF; цикл лишь ведёт лучи за узлами + (`syncPositionsFromDOM`). Завершение — гарантированно по таймеру (`endCssBloom`). +- **Ghost-слой:** снимок только **аватарок** старого графа (без линий — иначе старые связи висят + «ошмётками»). Полноэкранный overlay, застывает на месте, `scale 1→0.7` + `opacity 0.5→0` за + **1000мс**, затем удаляется (мягкий породистый шлейф истории). +- **Прорастание линий (Edge Growth):** новая линия тянется к ФИНАЛЬНОЙ точке узла и раскрывается + `stroke-dasharray`(=длина пути) + `stroke-dashoffset`(длина→0), синхронно с разлётом узла + (`growP = текущая дистанция / финальная`) → кончик «вытягивается» из центра вслед за аватаркой. + Старые линии при этом исчезают мгновенно. Только для новых узлов; переезжающие — линия следует за ними. +- **Физика (только до-settle):** после CSS-разлёта — лёгкая радиальная пружина + отталкивание для + органичного покачивания; после фильтра физика НЕ включается (фиксация на равномерных углах). - **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 — скорости обнуляются, координаты - округляются до целых пикселей, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает. -- **Линии:** SVG ` Q` (квадратичные Безье) — изящные изогнутые нити, тонкие/полупрозрачные; - при движении изгиб реагирует на скорость; новые линии прорастают (`stroke-dashoffset`). + округляются, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает. +- **Линии:** SVG ` Q` (квадратичные Безье) — тонкие изящные дуги (`stroke-width ~1.3–2.2`), + градиент неон-центр → цвет роли. Изгиб реагирует на скорость. +- **Сияющие связи:** линия к «сияющему» узлу — ярче (градиент в неон) и МОНОЛИТНО светится статичной + тенью `filter: drop-shadow(...)` (тот же приём, что у ободка аватарки). Без бегущих импульсов. - **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап - по центру — профиль. -- **Фильтры слоёв:** Все / Семья / Друзья / Сияющие (плавное скрытие/перераспределение). -- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса), «дыхание» фокуса (бесконечная CSS-анимация - размера 1.48–1.52x, GPU, не будит rAF), свечение «сияющих», хард-лимит ~90 DOM-аватарок (остальное — - SVG-точки). + по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не + перехватила указатель (`setPointerCapture`) и не «съела» click кнопки. +- **Фильтры слоёв (Все / Семья / Друзья / Сияющие):** CSS-переходы 300мс — несоответствующие узлы и их + линии гаснут НА МЕСТЕ (`opacity 0` + `scale 0.8`), оставшиеся плавно переплывают на равномерные углы, + затем жёсткая фиксация без физики (ноль тряски, мгновенный sleep). +- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация + размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной + `box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`); + хард-лимит ~90 DOM-аватарок (остальное — SVG-точки). ## Параметры тюнинга (константы в начале `force-graph.js`) | Константа | Значение | Назначение | @@ -53,11 +65,15 @@ | `K_RADIAL` | 0.035 | жёсткость орбитальной пружины (мягко) | | `K_FOCUS` | 0.12 | жёсткость пружины фокуса к центру | | `CHARGE` / `CHARGE_START_FACTOR` | 1400 / 0.45 | отталкивание (на старте ослаблено) | -| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.82 / 0.92 / 36 | базовое трение / стартовая вязкость / длительность (~600мс) | +| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.80 / 0.94 / 42 | базовое трение / стартовая вязкость / длительность (~700мс) | +| `BLOOM_MS` / `BLOOM_STAGGER` | 900 / 40 | длительность CSS-разлёта / задержка между узлами (каскад) | | `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки | | `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса | | `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) | +Прочее (вшито в код): Ghost-слой — 1000мс; CSS-переход фильтра — 300мс; пульсация сияния — 3.6с; +прорастание линий привязано к прогрессу разлёта узла (а не к отдельному таймеру). + ## Локальный запуск / проверка - Dev-сервер: `.claude/shine-ui-dev-server.cjs` (Node, порт 7321, SPA-fallback + инжект ``). - Лаборатория (без бэкенда): `http://localhost:7321/network-view/lab` — мок `networkGraphUsers`, diff --git a/shine-UI/js/mock-data.js b/shine-UI/js/mock-data.js index 081e18d..b44879b 100644 --- a/shine-UI/js/mock-data.js +++ b/shine-UI/js/mock-data.js @@ -308,7 +308,16 @@ const NETWORK_NAMES = { ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий', oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл', }; -const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg']); +const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg', 'marina', 'nina']); +// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории. +// Если сети нет — узлы мягко падают на инициалы (img.onerror). +const NETWORK_PHOTOS = { + ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5', + pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9', + dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52', + nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44', + sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60', +}; function networkConn(login, relationType, connectionStrength) { return { @@ -316,6 +325,7 @@ function networkConn(login, relationType, connectionStrength) { login, name: NETWORK_NAMES[login] || login, avatar: null, + photo: NETWORK_PHOTOS[login] || null, relationType, connectionStrength, hasOwnConnections: true, @@ -330,6 +340,7 @@ function networkPerson(login, connections) { login, name: NETWORK_NAMES[login] || login, avatar: null, + photo: NETWORK_PHOTOS[login] || null, status: NETWORK_SHINING.has(login) ? 'shining' : '', }, connections, diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 778c56d..595f53c 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -563,6 +563,9 @@ export function render({ navigate, route }) { // Панель фильтров слоёв (оверлей под шапкой) const filterBar = document.createElement('div'); filterBar.className = 'fg-filter-bar'; + // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, + // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. + filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); FILTER_ORDER.forEach((key) => { const chip = document.createElement('button'); chip.type = 'button'; diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index ea5ac66..7faddbf 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -14,7 +14,9 @@ // Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё): // model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] } -import { renderUserAvatar } from '../../components/avatar-image.js'; +import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js'; + +const SVGNS = 'http://www.w3.org/2000/svg'; // --- Параметры физики и анимации --------------------------------------------- const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px @@ -24,14 +26,16 @@ const K_FOCUS = 0.12; // мягкая пружина фокуса к const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется) const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники) const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания -const FRICTION = 0.82; // базовое затухание скорости (свободное покачивание) -const FRICTION_BOOST = 0.92; // максимальная вязкость в первые ~600мс после перестроения (гасит «взрыв») -const BOOST_FRAMES = 36; // длительность затухающего boost'а вязкости (~600мс @60fps) +const FRICTION = 0.80; // базовое затухание (после транзита — лёгкое упругое покачивание) +const FRICTION_BOOST = 0.94; // «гелевая» вязкость в первые ~700мс после перестроения (гасит «взрыв») +const BOOST_FRAMES = 42; // длительность затухающего boost'а вязкости (~700мс @60fps) const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| для жёсткой заморозки графа const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра const EDGE_LERP = 0.25; // догон концов линии за узлом за кадр (эффект натянутой резинки) const PAN_FRICTION = 0.93; // трение инерционного скролла карты -const TWEEN_MS = 560; // длительность анимации центрирования +const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк) +const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски) +const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x) const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей) @@ -46,15 +50,59 @@ const RELATION_COLORS = { contact: 'rgba(170, 190, 220, 0.7)', }; +// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла. +const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)'; + +// Яркий неон сияния — в него уходит градиент связи к «сияющему» узлу (совпадает со свечением аватарки). +const SHINE_EDGE_NEON = 'rgba(150, 245, 255, 0.95)'; + function easeOutCubic(t) { const x = 1 - t; return 1 - x * x * x; } +// Решатель кубической кривой Безье (CSS cubic-bezier): прогресс x → значение y. +function cubicBezier(x1, y1, x2, y2) { + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + const sampleX = (t) => ((ax * t + bx) * t + cx) * t; + const sampleY = (t) => ((ay * t + by) * t + cy) * t; + const dX = (t) => (3 * ax * t + 2 * bx) * t + cx; + return (x) => { + let t = x; + for (let i = 0; i < 6; i += 1) { + const d = dX(t); + if (Math.abs(d) < 1e-6) break; + t -= (sampleX(t) - x) / d; + } + return sampleY(Math.max(0, Math.min(1, t))); + }; +} +// Премиальная «вязкая» кривая для разлёта узлов (быстрый старт → очень мягкая посадка). +const EASE_BLOOM = cubicBezier(0.16, 1, 0.3, 1); + function relationColor(relationType) { return RELATION_COLORS[relationType] || RELATION_COLORS.contact; } +// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие). +// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается. +function ensureShineFilter() { + if (typeof document === 'undefined' || document.getElementById('fg-shine-glow')) return; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('width', '0'); + svg.setAttribute('height', '0'); + svg.style.position = 'absolute'; + svg.innerHTML = ''; + document.body.appendChild(svg); +} + // Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N. // Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали. function spreadAngle(index, total) { @@ -86,7 +134,7 @@ function hash01(str) { */ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) { // Слои DOM - const edgesSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const edgesSvg = document.createElementNS(SVGNS, 'svg'); edgesSvg.setAttribute('class', 'fg-edges'); const world = document.createElement('div'); world.className = 'fg-world'; @@ -95,6 +143,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const reticle = document.createElement('div'); reticle.className = 'fg-reticle'; stage.append(edgesSvg, world, reticle); + ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) // Состояние камеры (панорамирование) let camX = 0; @@ -118,14 +167,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла) let pendingFocusOrigin = null; - // Прогресс «прорастания» линий 0→1 (1 = полностью вычерчены) - let edgeGrowth = 1; - // Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра. let boost = 0; let frictionNow = FRICTION; let chargeNow = CHARGE; + // Режим CSS-bloom: узлы разлетаются нативными CSS-переходами (компоновщик, без JS-физики), + // цикл только перерисовывает лучи вслед за узлами. Завершается по таймеру. + let cssBloom = false; + let cssBloomTimer = 0; + let cssBloomKind = 'bloom'; // 'bloom' (каскадный разлёт) | 'filter' (фиксация на равномерных углах) + // Инерция панорамирования (kinematic panning) let panVelX = 0; let panVelY = 0; @@ -193,11 +245,32 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL opacity: 1, targetOpacity: 1, bloom: false, + edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) el, dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)), }; } + // Аватар из прямого URL-фото (тестовые данные лаборатории). Структура — как у renderUserAvatar + // (переиспользуем CSS .avatar/.node-dot), фолбэк на инициалы при ошибке загрузки (офлайн). + function buildPhotoAvatar(src) { + const wrap = document.createElement('div'); + wrap.className = 'avatar avatar-image node-dot'; + const fb = document.createElement('span'); + fb.className = 'avatar-fallback'; + fb.textContent = buildAvatarInitials({ login: src.login || String(src.id), firstName: src.name || '' }); + wrap.append(fb); + const img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + img.decoding = 'async'; + img.onload = () => wrap.classList.add('has-image'); + img.onerror = () => { img.remove(); }; // нет сети — остаются инициалы + img.src = src.photo; + wrap.append(img); + return wrap; + } + function buildNodeElement(src, isFocus, tier, dotOnly = false) { const el = document.createElement('button'); el.type = 'button'; @@ -221,13 +294,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); - const avatar = renderUserAvatar({ - login: src.login || src.name || String(src.id), - firstName: src.name || '', - avatar: src.avatar || null, - size: 'node', - title: src.name || src.login || '', - }); + // тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы) + const avatar = src.photo + ? buildPhotoAvatar(src) + : renderUserAvatar({ + login: src.login || src.name || String(src.id), + firstName: src.name || '', + avatar: src.avatar || null, + size: 'node', + title: src.name || src.login || '', + }); el.append(avatar); const label = document.createElement('span'); @@ -295,51 +371,83 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const focusLogin = String(focus.login || '').toLowerCase(); const parts = []; + const defs = []; + let gi = 0; for (const n of nodes) { if (n === focus) continue; - if (n.hidden) continue; + // скрытый фильтром узел: рисуем луч пока он гаснет (живая прозрачность > 0), затем пропускаем + const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1; + if (n.hidden && nodeOpacity <= 0.02) continue; if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue; const nx = tx(n); const ny = ty(n); if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue; - const dx = nx - fx; - const dy = ny - fy; + // Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке + // и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/ + // общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash). + const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1; + const ex = growing ? (centerX + camX + n.bfx) : nx; + const ey = growing ? (centerY + camY + n.bfy) : ny; + const dx = ex - fx; + const dy = ey - fy; const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; const nr = n.dotRadius * n.scale + 4; - // концы линии — у краёв кружков (по истинной позиции) + // концы линии — у краёв кружков const x1 = fx + ux * fr; const y1 = fy + uy * fr; - const x2 = nx - ux * nr; - const y2 = ny - uy * nr; - // контрольная точка кривой Безье: постоянный лёгкий изгиб (провисание) перпендикулярно - // линии + динамика от запаздывания (при движении узла нить выгибается сильнее) + const x2 = ex - ux * nr; + const y2 = ey - uy * nr; + // контрольная точка кривой Безье: постоянная изящная дуга (перпендикуляр) + + // прогиб НАЗАД против вектора скорости узла (резиновый жгут); в покое — идеальная дуга const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2; const segLen0 = Math.hypot(x2 - x1, y2 - y1); - // изгиб строго перпендикулярный: заметная постоянная дуга (≈7–22px) + - // динамика от скорости узла → при движении нить выгибается, как натянутая резина + const baseBow = Math.max(7, Math.min(20, segLen0 * 0.12)); // постоянная дуга const speed = Math.hypot(n.vx, n.vy); - const bow = Math.max(7, Math.min(22, segLen0 * 0.13)) + Math.min(16, speed * 1.2); - const cpx = mx + (-uy) * bow * 2; // CP даёт середину Q-кривой = M + perp*bow - const cpy = my + ux * bow * 2; - // минимализм: тонкие (1.3–1.8px), полупрозрачные линии — без «энергетических лучей» - const w = 1.3 + n.strength * 0.5; - // прорастание: длину пути приближаем хордой, dash-offset → 0 - let dash = ''; - if (edgeGrowth < 1) { - const segLen = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy)) || 1; - dash = ` stroke-dasharray="${segLen.toFixed(1)}" stroke-dashoffset="${(segLen * (1 - edgeGrowth)).toFixed(1)}"`; - } - parts.push( - `` + const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости + const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения + const invY = speed > 0.01 ? -n.vy / speed : 0; + // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости + const desX = mx + (-uy) * baseBow + invX * lag; + const desY = my + ux * baseBow + invY * lag; + const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired + const cpy = 2 * desY - my; + // ТОНКАЯ изящная дуга: одинарная квадратичная кривая Безье, лёгкий градиентный штрих. + // Обычная связь — неон у центра → цвет роли у узла. Связь к «СИЯЮЩЕМУ» — ярче, уходит в + // неон сияния и МОНОЛИТНО светится (статичный drop-shadow через класс .fg-edge-shine). + const shine = Boolean(n.shining) && !n.hidden; + const gid = `fg-grad-${gi}`; + gi += 1; + const tipColor = shine ? SHINE_EDGE_NEON : relationColor(n.relationType); + const baseStop = shine ? 0.85 : 0.5; + const tipStop = shine ? 0.7 : 0.14; + defs.push( + `` + + `` + + `` ); + const sw = (shine ? 1.7 + n.strength * 0.8 : 1.3 + n.strength * 0.9).toFixed(2); // тонко + const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; + // прозрачность луча = живая прозрачность узла (гаснет вместе с узлом при фильтре/уходе) + const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; + // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset уводим от длины к 0 по мере + // разлёта (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра. + let dashAttr = ''; + if (growing) { + const finalD = Math.hypot(n.bfx, n.bfy) || 1; + const curD = Math.hypot(n.x, n.y); + const growP = Math.max(0, Math.min(1, curD / finalD)); + const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2; + dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; + } + const cls = shine ? ' class="fg-edge-shine"' : ''; // монолитное неоновое свечение (drop-shadow) + parts.push(``); } - edgesSvg.innerHTML = parts.join(''); + edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`; } function updateReticle() { @@ -413,18 +521,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } } - // Плавное приближение масштаба/прозрачности к целям (bloom новых, рост/уменьшение при смене роли). + // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»). function advanceVisual() { for (const n of nodes) { n.scale += (n.targetScale - n.scale) * 0.2; n.opacity += (n.targetOpacity - n.opacity) * 0.2; + // линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить + if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08); } } - // Не «успокоились» ли ещё визуальные параметры (для условия заморозки). + // Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки). function visualSettling() { for (const n of nodes) { - if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01) return true; + if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true; } return false; } @@ -476,28 +586,41 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL wake(); } + // Универсальный твин (физика выключена → ноль тряски). Поддерживает длительность, кривую и + // поканальную задержку (каскад) + рост линий. Используется для bloom-разлёта, фильтра, центрирования. function stepTween(ts) { if (!tween.startTs) tween.startTs = ts; - const raw = Math.min(1, (ts - tween.startTs) / TWEEN_MS); - const t = easeOutCubic(raw); + const dur = tween.dur || TWEEN_MS; + const ease = tween.ease || easeOutCubic; + const elapsed = ts - tween.startTs; + let allDone = true; for (const n of nodes) { const a = tween.from.get(n.id); const b = tween.to.get(n.id); if (!a || !b) continue; + let raw = (elapsed - (a.delay || 0)) / dur; + if (raw < 0) raw = 0; // узел ещё не «выпущен» — держим в стартовой точке + if (raw < 1) allDone = false; + raw = Math.min(1, raw); + const t = ease(raw); n.x = a.x + (b.x - a.x) * t; n.y = a.y + (b.y - a.y) * t; n.scale = a.scale + (b.scale - a.scale) * t; const ao = a.opacity ?? 1; const bo = b.opacity ?? 1; n.opacity = ao + (bo - ao) * t; + n.lerpX = n.x; n.lerpY = n.y; + if (b.grow) n.edgeGrow = raw; // линия «вытекает» по прогрессу своего узла } - camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * t; - camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * t; + const camT = ease(Math.min(1, elapsed / dur)); + camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * camT; + camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * camT; applyWorldTransform(); - if (raw >= 1) { + if (allDone) { + const wasBloom = tween.idleBoost; tween = null; // твин завершён - // синхронизируем цели визуала с текущими, чтобы advanceVisual не «откатил» (важно для фильтра) - for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; } + for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; n.edgeGrow = 1; } + if (wasBloom) boost = 1; // в покое — лёгкое «гель»-демпфированное упругое покачивание } } @@ -509,12 +632,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } // Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден. - // Видимые перераспределяются по орбите, скрытые плавно гаснут (scale↓ + opacity→0). + // Перестроение идёт нативными CSS-переходами (компоновщик): работает даже когда rAF-цикл + // троттлится в простое (иначе граф «не перестраивался» бы). Скрытые узлы плавно гаснут НА МЕСТЕ + // (scale 0.8 + opacity 0 за 300мс), видимые плавно переплывают на равномерные углы орбиты; + // лучи скрываемых гаснут вместе с ними (renderEdges читает живую прозрачность из DOM). + // По завершении — жёсткая фиксация на этих углах БЕЗ физики (ноль тряски, идеальный sleep). function setFilter(predicate) { const pred = typeof predicate === 'function' ? predicate : () => true; - const from = new Map(); - const to = new Map(); - nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale, opacity: n.opacity })); + if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; } + cancelTween(); // на случай активного JS-твина центрирования — отдаём управление CSS-переходу const visiblePeers = []; nodes.forEach((n) => { @@ -525,8 +651,19 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (!n.hidden) visiblePeers.push(n); }); + const FILTER_MS = 300; + const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`; + // применяем целевое состояние как CSS-переход; финал кэшируем в bfx/bfy/bfs/bfo для endCssBloom + const apply = (n, x, y, s, o) => { + n.bfx = x; n.bfy = y; n.bfs = s; n.bfo = o; + n.el.style.transition = `transform ${FILTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${FILTER_MS}ms ease`; + n.el.style.transform = tf(x, y, s); + n.el.style.opacity = String(o); + n.el.style.pointerEvents = o <= 0.01 ? 'none' : ''; + }; + const focus = nodes.find((n) => n.isFocus); - if (focus) to.set(focus.id, { x: 0, y: 0, scale: FOCUS_SCALE, opacity: 1 }); + if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1); visiblePeers.forEach((n, i) => { n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN); @@ -534,17 +671,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.tx = Math.cos(n.angle) * n.targetR; n.ty = Math.sin(n.angle) * n.targetR; const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE; - to.set(n.id, { x: n.tx, y: n.ty, scale: sc, opacity: 1 }); + apply(n, n.tx, n.ty, sc, 1); }); nodes.forEach((n) => { if (n.isFocus || !n.hidden) return; - // скрытые: подтягиваем к центру и гасим - to.set(n.id, { x: n.x * 0.35, y: n.y * 0.35, scale: 0.2, opacity: 0 }); + // скрытые: растворяются ПРЯМО НА МЕСТЕ (scale 0.8 + opacity 0 за 300мс) — мягкий фейд, без «вылетов» + apply(n, n.x, n.y, 0.8, 0); }); - // фильтр не двигает камеру (в отличие от центрирования) - tween = { startTs: 0, from, to, camFrom: { x: camX, y: camY }, camTo: { x: camX, y: camY } }; + // режим CSS-перехода: цикл лишь ведёт лучи за узлами; по таймеру — фиксация без физики + cssBloom = true; + cssBloomKind = 'filter'; + renderEdges(); + cssBloomTimer = window.setTimeout(endCssBloom, FILTER_MS + 60); wake(); } @@ -568,6 +708,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function tick(ts) { rafId = 0; + // режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики) + if (cssBloom) { + syncPositionsFromDOM(); + renderEdges(); + updateReticle(); + schedule(); + return; + } + // инерция панорамирования (kinematic): камера докатывается с трением const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15); if (panActive) { @@ -581,10 +730,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL panVelY = 0; } - if (edgeGrowth < 1) edgeGrowth = Math.min(1, edgeGrowth + 0.07); // прорастание линий ~15 кадров - - // динамическая вязкость: первые ~400мс после перестроения трение выше (0.90→0.82), - // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту мягко + // динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80), + // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле» frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION); chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost); if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES); @@ -601,7 +748,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL renderAll(); const lerpSettling = nodes.some((n) => Math.abs(n.x - n.lerpX) + Math.abs(n.y - n.lerpY) > 0.5); - if (tween || dragging || panActive || edgeGrowth < 1 || boost > 0 || totalV > SLEEP_V || lerpSettling || visualSettling()) { + if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || lerpSettling || visualSettling()) { schedule(); } else { freezeGraph(); // система успокоилась — замираем @@ -724,24 +871,22 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } // --- Жизненный цикл узлов (diffing) ---------------------------------------- - // Ghost-слой = СНИМОК всего старого графа (узлы + линии) на полноэкранном слое. - // Клон застывает СТРОГО НА МЕСТЕ (полноэкранный overlay → координаты не сбрасываются), - // плавно уменьшается (scale 1→0.7) и растворяется (opacity 0.4→0) за 500мс — красивый - // шлейф истории перехода, — после чего полностью удаляется из DOM. + // Ghost-слой = СНИМОК старых АВАТАРОК (без линий!) на полноэкранном слое. Линии в шлейф НЕ + // копируем намеренно: старые связи должны исчезать мгновенно вместе с перерисовкой графа, а не + // висеть «ошмётками» секунду. Клон застывает на месте и лениво тает (scale 1→0.7 + opacity→0) + // за 1000мс — мягкий породистый шлейф истории, — затем удаляется из DOM (строго через 1000мс). function spawnGhost() { if (!world.childElementCount) return; const ghost = document.createElement('div'); ghost.className = 'fg-ghost-layer'; - const edgesClone = edgesSvg.cloneNode(true); // .fg-edges (inset:0) → линии совпадают по координатам - edgesClone.style.opacity = ''; // снимаем возможный inline-fade, слой задаёт прозрачность сам - const worldClone = world.cloneNode(true); // .fg-world (центр) → узлы на своих местах + const worldClone = world.cloneNode(true); // .fg-world (центр) → только узлы на своих местах worldClone.style.transform = world.style.transform || ''; - ghost.append(edgesClone, worldClone); + ghost.append(worldClone); stage.insertBefore(ghost, edgesSvg); // позади живых слоёв void ghost.offsetWidth; // рефлоу для запуска CSS-перехода ghost.style.transform = 'scale(0.7)'; ghost.style.opacity = '0'; - window.setTimeout(() => ghost.remove(), 800); + window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс } // Импульс центрального кольца — подтверждение «захвата» нового фокуса. @@ -752,6 +897,48 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL window.setTimeout(() => reticle.classList.remove('is-pulse'), 620); } + // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM, + // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками. + function syncPositionsFromDOM() { + const sr = stage.getBoundingClientRect(); + for (const n of nodes) { + const dot = n.el.querySelector('.node-dot') || n.el; + const r = dot.getBoundingClientRect(); + n.x = (r.left + r.width / 2) - sr.left - centerX - camX; + n.y = (r.top + r.height / 2) - sr.top - centerY - camY; + n.lerpX = n.x; n.lerpY = n.y; + // живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой + const o = parseFloat(getComputedStyle(n.el).opacity); + if (Number.isFinite(o)) n.opacity = o; + } + } + + // Завершение CSS-bloom (по таймеру — гарантированно, даже при троттлинге rAF): + // снимаем переходы, ставим узлы в финал и включаем лёгкую физику покачивания в покое. + function endCssBloom() { + cssBloomTimer = 0; + if (!cssBloom) return; + cssBloom = false; + for (const n of nodes) { + n.el.style.transition = ''; + const fo = (typeof n.bfo === 'number') ? n.bfo : 1; // финальная прозрачность (0 — скрыт фильтром) + n.x = n.bfx; n.y = n.bfy; n.lerpX = n.x; n.lerpY = n.y; + n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo; + n.vx = 0; n.vy = 0; n.edgeGrow = 1; + } + if (cssBloomKind === 'filter') { + // ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся + // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + freezeGraph(); + return; + } + boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80) + renderNodes(); + renderEdges(); + wake(); + } + // Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний: // • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита); // • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1); @@ -761,75 +948,86 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const newIds = new Set(specs.map((s) => s.id)); const oldById = new Map(nodes.map((n) => [String(n.id), n])); - // точка рождения новых узлов = текущая позиция нового фокуса (откуда он «исходит») + // старый узел нового фокуса (если был) — фокус глайдит из его позиции const focusOld = oldById.get(String(newFocusId)); - const originX = focusOld ? focusOld.x : (pendingFocusOrigin ? pendingFocusOrigin.x : 0); - const originY = focusOld ? focusOld.y : (pendingFocusOrigin ? pendingFocusOrigin.y : 0); // снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира spawnGhost(); nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); }); focusId = String(newFocusId); - edgeGrowth = 0; // линии к новым узлам прорастают из центра - boost = 1; // включаем повышенную вязкость на ~400мс (гасим энергию разлёта) + if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; } + + const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`; + let order = 0; + let maxDelay = 0; + const blooms = []; - const fresh = []; - let bloomOrder = 0; nodes = specs.map((spec) => { - const old = oldById.get(spec.id); - if (old && old.dotOnly === spec.dotOnly) { - updateNodeRole(old, spec); // непрерывность: тот же DOM, новая цель → перелёт пружиной - return old; - } - if (old) old.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент - const node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly); - // периферия «выстреливает» из центрального круга (0,0); смещение вдоль угла даёт направление силам - const bx = Math.cos(node.angle) * 14; - const by = Math.sin(node.angle) * 14; - node.x = node.isFocus ? originX : bx; - node.y = node.isFocus ? originY : by; - node.lerpX = node.x; node.lerpY = node.y; - node.scale = 0.01; node.opacity = 0; node.bloom = true; - node.bloomScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (Number(spec.src.tier) >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); - if (node.isFocus) { - node.targetScale = node.bloomScale; node.targetOpacity = 1; // фокус виден сразу (влетает) + const oldNode = oldById.get(spec.id); + let node; + let isNew; + if (oldNode && oldNode.dotOnly === spec.dotOnly) { + updateNodeRole(oldNode, spec); // непрерывность: тот же DOM, новая роль/орбита + node = oldNode; isNew = false; } else { - // периферия: держим скрытой в центре и «выстреливаем» по очереди (каскад 40мс) - node.hidden = true; - node.targetScale = 0; node.targetOpacity = 0; - node.bloomOrder = bloomOrder++; - fresh.push(node); + if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент + node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly); + isNew = true; } + + // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty) + const finalX = node.isFocus ? 0 : node.tx; + const finalY = node.isFocus ? 0 : node.ty; + const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); + + // стартовая точка разлёта + let fx; let fy; let fs; let fo; let delay = 0; + if (node.isFocus) { + if (focusOld) { fx = focusOld.x; fy = focusOld.y; fs = focusOld.scale; fo = 1; } // глайд из старой позиции + else if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) { + fx = pendingFocusOrigin.x; fy = pendingFocusOrigin.y; fs = 0.6; fo = 1; // влёт из точки клика + } else { fx = 0; fy = 0; fs = 0.3; fo = 0; } // первичная инициализация + } else if (isNew) { + fx = Math.cos(node.angle) * 12; fy = Math.sin(node.angle) * 12; fs = 0.2; fo = 0; // из центрального круга + order += 1; delay = order * BLOOM_STAGGER; // каскад (волна) + } else { + fx = node.x; fy = node.y; fs = node.scale; fo = node.opacity; // непрерывность: с текущего места + } + + // финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей) + node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1; + node.x = fx; node.y = fy; node.lerpX = fx; node.lerpY = fy; + node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1; + node.hidden = false; + // НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0); + // переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия). + node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1; + + maxDelay = Math.max(maxDelay, delay); + blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay }); return node; }); - - // новый фокус «влетает» из точки клика (если кликнули по периферийному узлу) - if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) { - const f = nodes.find((n) => n.isFocus); - if (f && !focusOld) { f.x = pendingFocusOrigin.x; f.y = pendingFocusOrigin.y; f.lerpX = f.x; f.lerpY = f.y; } - } pendingFocusOrigin = null; - // каскад: каждый новый узел освобождается из центра через order*40мс → волна - fresh.forEach((node) => { - window.setTimeout(() => { - node.hidden = false; - node.targetScale = node.bloomScale; - node.targetOpacity = 1; - wake(); - }, node.bloomOrder * 40); - }); + camX = 0; camY = 0; applyWorldTransform(); - camX = 0; - camY = 0; - applyWorldTransform(); - renderAll(); - // линии: плавно проявляем (старые ушли с призраком) - edgesSvg.style.opacity = '0'; - void edgesSvg.offsetWidth; - edgesSvg.style.opacity = '1'; + // ПАСС 1: стартовое состояние без перехода + for (const b of blooms) { b.el.style.transition = 'none'; b.el.style.transform = b.start; b.el.style.opacity = String(b.fo); } + void world.offsetWidth; // один форс-рефлоу, чтобы старт «зафиксировался» + // ПАСС 2: включаем CSS-переход и ставим финал → компоновщик плавно «по маслу» + // (работает даже когда JS-rAF троттлится; премиальная вязкая кривая Apple-уровня) + for (const b of blooms) { + b.el.style.transition = `transform ${BLOOM_MS}ms cubic-bezier(0.16, 1, 0.3, 1) ${b.delay}ms, opacity 700ms ease ${b.delay}ms`; + b.el.style.transform = b.final; + b.el.style.opacity = '1'; + } + + cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами + cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost) + renderEdges(); pulseReticle(); + cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано wake(); } @@ -880,6 +1078,7 @@ export function buildModelFromTz(tz) { login: focus.login || focus.id || '', name: focus.name || '', avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null, + photo: focus.photo || null, relationType: 'self', strength: 1, shining: String(focus.status || '').toLowerCase() === 'shining', @@ -891,6 +1090,7 @@ export function buildModelFromTz(tz) { login: c.login || c.id || '', name: c.name || '', avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null, + photo: c.photo || null, relationType: c.relationType || 'contact', strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5, shining: String(c.status || '').toLowerCase() === 'shining', diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 233184d..dc2a655 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -12,6 +12,16 @@ import { openNodeMenu } from './node-menu.js'; const START_LOGIN = 'ivan'; +// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам; +// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие». +const FILTERS = { + all: { label: 'Все', pred: () => true }, + family: { label: 'Семья', pred: (n) => n.relationType === 'family' }, + friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' }, + shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, +}; +const FILTER_ORDER = ['all', 'family', 'friends', 'shining']; + function helpText() { return [ 'Лаборатория карты связей (мок-данные, без сервера).', @@ -19,6 +29,7 @@ function helpText() { '• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.', '• Тап по центральному узлу — здесь открылся бы профиль.', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', + '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', '', 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', ].join('\n'); @@ -55,6 +66,19 @@ export function renderNetworkLab({ navigate }) { const model = buildModelFromTz(graphForLogin(START_LOGIN)); + // Состояние активного слоя (как в network-view): фокус всегда виден. + let activeFilter = 'all'; + const filterChips = {}; + function applyFilter(key) { + if (!FILTERS[key]) return; + activeFilter = key; + FILTER_ORDER.forEach((k) => { + const el = filterChips[k]; + if (el) el.classList.toggle('is-active', k === activeFilter); + }); + graph.setFilter(FILTERS[key].pred); + } + stage.append(header); screen.append(stage); @@ -64,7 +88,11 @@ export function renderNetworkLab({ navigate }) { stage, model, // тап по узлу — переключаем карту на сеть выбранного человека - onNodeTap: (node) => { graph.setModel(buildModelFromTz(graphForLogin(node.login))); }, + onNodeTap: (node) => { + graph.setModel(buildModelFromTz(graphForLogin(node.login))); + // сохраняем выбранный слой при переходе на сеть другого человека (как в network-view) + if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); + }, onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`), onNodeLongPress: (node, point) => openNodeMenu({ login: node.name || node.login || node.id, @@ -77,6 +105,23 @@ export function renderNetworkLab({ navigate }) { }), }); + // Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view. + const filterBar = document.createElement('div'); + filterBar.className = 'fg-filter-bar'; + // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, + // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. + filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); + FILTER_ORDER.forEach((key) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`; + chip.textContent = FILTERS[key].label; + chip.addEventListener('click', () => applyFilter(key)); + filterChips[key] = chip; + filterBar.append(chip); + }); + stage.append(filterBar); + screen.cleanup = () => { graph.destroy(); appScreenEl?.classList.remove('network-scroll-lock'); diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index a8957cd..c86277b 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -37,6 +37,12 @@ transition: opacity 420ms ease; /* плавное появление линий при перестройке */ } +/* Связь к «сияющему» узлу — МОНОЛИТНО светится мягким неоном (тот же приём, что у ободка аватарки): + статичная двухслойная тень через drop-shadow. Никакой динамики, бегущих точек и пульсаций. */ +.fg-edge-shine { + filter: drop-shadow(0 0 3px rgba(140, 235, 255, 0.75)) drop-shadow(0 0 6px rgba(110, 225, 255, 0.4)); +} + .fg-node { position: absolute; left: 0; @@ -99,24 +105,52 @@ box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35); } -/* пульсирующее свечение «сияющих» узлов */ +/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи. + Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр). + Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 ↔ 1.0 — + как мягкое свечение живого организма в темноте, а не «жирный маркер». */ +.fg-node.is-shine .node-dot { + border-color: rgba(150, 240, 255, 0.62); + animation: fg-shine-glow 3.6s ease-in-out infinite; +} + +/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */ .fg-node.is-shine .node-dot::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; - background: radial-gradient(circle, rgba(130, 235, 255, 0.6) 0%, rgba(130, 235, 255, 0.26) 44%, rgba(130, 235, 255, 0) 76%); - filter: blur(2px); + background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%); + filter: url(#fg-shine-glow); z-index: -1; - animation: fg-shine-pulse 2.4s ease-in-out infinite; + pointer-events: none; + animation: fg-shine-halo 3.6s ease-in-out infinite; } -@keyframes fg-shine-pulse { - 0%, 100% { transform: scale(0.92); opacity: 0.5; } - 50% { transform: scale(1.16); opacity: 0.95; } +/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */ +@keyframes fg-shine-glow { + 0%, 100% { + box-shadow: + 0 0 5px rgba(125, 232, 255, 0.30), + 0 0 11px rgba(112, 226, 255, 0.18), + 0 0 20px rgba(100, 220, 255, 0.10); + } + 50% { + box-shadow: + 0 0 9px rgba(150, 245, 255, 0.62), + 0 0 20px rgba(122, 236, 255, 0.42), + 0 0 36px rgba(100, 220, 255, 0.26); + } +} + +/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */ +@keyframes fg-shine-halo { + 0%, 100% { transform: scale(0.9); opacity: 0.5; } + 50% { transform: scale(1.12); opacity: 1; } } @media (prefers-reduced-motion: reduce) { + .fg-node.is-shine .node-dot { animation: none; } .fg-node.is-shine .node-dot::before { animation: none; } } @@ -230,7 +264,8 @@ z-index: 0; opacity: 0.5; /* стартовая прозрачность шлейфа выше → дольше читается (JS уводит в 0) */ transform-origin: 50% 50%; - transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 800ms cubic-bezier(0.16, 1, 0.3, 1); + /* лениво и породисто тает на месте за 1000мс — медленный дорогой шлейф истории перехода */ + transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease; } /* Импульс центрального кольца при захвате нового фокуса */