Всё в лаборатории (вариант 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) <noreply@anthropic.com>
19 KiB
Интерактивная карта связей (force-directed graph)
Экран «Связи» (network-view) — интерактивная нод-граф карта вместо статичного списка:
фокусный пользователь в центре, связи на орбите, навигация тапом/свайпом, премиальные
переходы в духе нативного iOS.
Где код
js/pages/network/force-graph.js— движок (физика, рендер, жизненный цикл узлов, жесты).js/pages/network/adapter.js— реальные данные → нейтральная модель движка.js/pages/network/node-menu.js— общее контекстное меню узла.js/pages/network/lab.js— лаборатория (network-view/lab) на мок-данных, без бэкенда.js/pages/network-view.js— страница: шапка, поиск, фильтры, история, склейка с движком.js/mock-data.js—networkGraphUsers(связанный мульти-граф из 10 человек для лаборатории).styles/network-graph.css— все стили.fg-*.
Данные (read-only, сервер не трогаем)
Единый источник — authService.getUserConnectionsGraph(login) (один запрос: логин → прямые связи).
network-view.js → buildGraphModel() нормализует роли (parent/child/sibling/spouse/friend/contact),
направление и метки; adapter.engineModelFromGraphModel() превращает это в модель движка:
{ focusId, nodes:[{ id, login, name, avatar, relationType, strength, shining, tier }] }.
Модель движка и API
createForceGraph({ stage, model, onNodeTap, onCenterTap, onNodeLongPress }) →
{ setModel(model), setFilter(pred), recenter(id), getFocusNode(), destroy() }.
Ключевые механики
- Diffing-переходы (непрерывность состояний): при смене фокуса общие узлы (тот же
id) не пересоздаются, а перелетают на новые места; новые «расцветают» (bloom) каскадом из центра; исчезнувшие уходят в Ghost-слой. - 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
<path> Q(квадратичные Безье) — тонкие матовые дуги (stroke-width ~1.0–1.2), градиент с глубоким уходом в прозрачность: неон-центр0.42→ цвет роли0.07у аватарки (растворяются в фоне, не спорят с сияющими). Изгиб реагирует на скорость. - Сияющие связи (двухслойный «световод», Neon Layering): два пути на одну связь — широкий размытый
GLOW (
stroke-width 4, неон,filter: blur(2px),opacity 0.4) + тонкий чёткий CORE (1.5px,#e0f7fc). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий dashoffset). Никаких бегущих импульсов. - Жесты: свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
сниппет; долгий тап — контекстное меню (в
#modal-root, позиция поgetBoundingClientRect); тап по центру — профиль. Нажатия на чипы фильтров гасятpointerdown(stopPropagation), чтобы сцена не перехватила указатель (setPointerCapture) и не «съела» click кнопки. - Фильтры слоёв (Все / Семья / Друзья / Сияющие): CSS-переходы 300мс — несоответствующие узлы и их
линии гаснут НА МЕСТЕ (
opacity 0+scale 0.8), оставшиеся плавно переплывают на равномерные углы, затем жёсткая фиксация без физики (ноль тряски, мгновенный sleep). - Живой фон (Nebula): под центром — глубокое размытое сине-голубое облако (
.fg-stage::before,blur 80→96px), бесконечная анимация 7с: «дышит» радиусом/яркостью и переливается индиго↔ультрамарин (hue-rotate). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными. - Стеклянные чипы фильтров (frosted glass):
background: rgba(255,255,255,0.03),backdrop-filter: blur(12px), граница0.5px solid rgba(255,255,255,0.1); активный — подсвечен сине-голубым. - Поллиш: «дыхание» фокуса (бесконечная CSS-анимация
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
box-shadow+ размытый ореол через SVG-фильтр#fg-shine-glow; тестовые фото-аватарки (NETWORK_PHOTOS); хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
Параметры тюнинга (константы в начале force-graph.js)
| Константа | Значение | Назначение |
|---|---|---|
ORBIT_MIN / ORBIT_MAX |
150 / 240 | радиус орбиты (защитный отступ от центра — подписи не наезжают) |
K_RADIAL |
0.035 | жёсткость орбитальной пружины (мягко) |
K_FOCUS |
0.12 | жёсткость пружины фокуса к центру |
CHARGE / CHARGE_START_FACTOR |
1400 / 0.45 | отталкивание (на старте ослаблено) |
FRICTION / FRICTION_BOOST / BOOST_FRAMES |
0.80 / 0.94 / 42 | базовое трение / стартовая вязкость / длительность (~700мс) |
BLOOM_MS / BLOOM_STAGGER |
900 / 40 | длительность CSS-разлёта / задержка между узлами (каскад) |
SLEEP_V |
0.03 | порог суммарной |
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 + инжект<base href="/">). - Лаборатория (без бэкенда):
http://localhost:7321/network-view/lab— мокnetworkGraphUsers, тап по узлам переключает сети. - Реальный путь (
/network-view) требует живогоwss://shineup.me/ws(локально —ws_open_error, это норма).
Режим «Интерактивная паутина» (ветка pixel-web, эксперимент, только лаборатория)
Включается чипом «🌌 Вселенная». Дальние уровни (2-3) по умолчанию скрыты и раскрываются локально:
- Hover-превью (наведение): навёл мышь/палец на узел — его ветка временно выплывает; убрал —
втягивается. Реализация:
pointerover/out(мышь) иpointerdown/up(палец) →onNodeHover→graph.setHover(node|null); узел получает флагhovered. - Фиксация кликом (pin): тап/клик по узлу →
graph.toggleExpandставит флагpinned— ветка остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу сворачивает его (надёжный toggle:isOpen = pinned || expandP>0.5→ сбросpinned+hovered). - Эффективное раскрытие =
pinned || hovered(см.expandTargetOf), прогрессexpandP(~400мс). - Spotlight-затемнение: пока есть закреплённая ветка, остальные тускнеют до
SPOTLIGHT_DIM=0.25(узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно черезspotCur(lerp). - Узлы 2-го уровня — полноценные аватарки: фото-лицо (pravatar) + имя,
DEEP2_SCALE=0.62(≈radius 16px),DEEP2_OPACITY=0.85. Не «пустые кружки», а видимые друзья друзей. - Глобальный сброс: тап по корню (Иван) →
collapseAll()снимаетpinned/hovered→ 100% яркость. - Адаптивное расталкивание (collision): раскрытая ветка усиливает отталкивание соседних узлов
1-го уровня пропорционально
expandP(EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь. - Камера-доводчик: при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
дотягивается (
glideCameraTo→camTargetX/Y, lerpCAM_GLIDE_Kв tick). Любой жест отменяет доводчик. - Свободный зум: колесо мыши (
onWheel) и щипок двумя пальцами (activePointers/pinching) — масштабzoom(0.55–2.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-scale, линии (отдельный SVG) пересчитываются в экранных координатах (×zoom). - Синхро-пульс линий: сияющие/трековые «световоды» (
.fg-edge-glow/.fg-edge-core) «дышат» толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно). - Мерцающие микрозвёзды 3-го уровня (
fg-star-twinkle), хаптика (navigator.vibrate) на нажатие/раскрытие/натяжение.
Умный фокус (Smart Zoom / «аквариум») — ветка pixel-aquarium
Наведение (hover/палец) на узел — лёгкое превью ветки (раскрытие на месте, без камеры). Клик/тап по ЛЮБОМУ узлу — погружение (dive) с кинематографичным наездом:
- Камера-полёт + зум (
diveTo→diveTargetId/diveZoom=1.7, лёт вtickсDIVE_FLY_K≈600мс): узел плавно центрируется (offset ~0) и вырастает до единого видимого размераHERO_VISUAL=1.4независимо от уровня (depthScale = HERO_VISUAL / baseScaleOf); его прямые дети — доDIVE_CHILD_VISUAL. - Адаптивный радиус орбиты (фикс слипания): дети раскладываются на кольце
ringR = max(baseR + радиус_родителя, число_детей × 13)— НЕ лезут на (увеличенный зумом) родитель и друг на друга (проверено: мин. дистанция 125px, 0 наложений). Радиус растёт вместе с зумом родителя. - Глубина «аквариума» (
contextTargetOf→depthScale/depthBlur/spotCur, лерп): Иван и боковые ветки уменьшаются (root ×0.55, фон ×0.55) + уходят в blur 3px + тускнеют до 0.25 → задний план. - Железный Spotlight (единый активный путь):
diveToсначала гасит ВСЕ прежние pin/hover, затем раскрывает только путь к новой цели. Открыто → путь Иван→…→узел = 1.0, остальное = 0.25; переключение веток сбрасывает прежнюю; выход/exitDive/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает. - Нить-крошка: путь (
divePathSet/onPath) горит ярким «световодом» — виден путь назад к Ивану. - Pinch-to-Zoom + LOD: щипок/колесо меняют
zoom; приzoom ≥ LOD_ZOOM (1.55)видимые точки 3-го уровня дорисовываются как аватарки (updateLod/setNodeLod), при отдалении — обратно в точки. - Глубина — фейк-3D через масштаб + CSS-
blur(GPU), без WebGL.
Полиш (партия 1): веер детей раскрывается полукругом «наружу» (от пути назад, DEEP_FAN,
по sibIndex) — не перекрывает нить-крошку; LOD с гистерезисом (LOD_ZOOM_UP=1.6/DOWN=1.4 — без
мигания у порога); двойной тап по фону и сильный pinch-out на мин. зуме = быстрый выход;
префетч аватарок детей при наведении/нырке.
Фишки (партия 2, лаборатория):
- Поиск + телепорт — строка
.fg-search; Enter →graph.findNode(имя)→ камера летит к узлу (dive в «Вселенной», иначе перецентр). - Хлебные крошки —
.fg-breadcrumb«Иван › Нина › Ада» (движок шлётonDiveChange(path), APIgetDivePath()); клик по корню — полный сброс, по предку — навигация на тот уровень. - Бейдж числа связей —
.fg-node-badge(число изdegreeById, обновляется вupdateBadges). - Цветовые кластеры — мягкая аура узла по типу связи (CSS
is-family/friend/business/contact).
Автопроверки (?fgtest): js/pages/network/selftest.js автозапускается в лаборатории при ?fgtest,
прогоняет 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 (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны,updateLodвыходит при!hasDeep.
Ограничения / на будущее
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи» упираются в API (отдаёт только прямые связи) — требуют доработки сервера.
- Превью в простое троттлит
requestAnimationFrame(физика не идёт между вызовами) — для замеров прокачивать кадры; в активном табе всё работает на 60 FPS.