Compare commits

..

17 Commits

Author SHA256 Message Date
AidarKC
01d9553db4 merge(ui): влить PR #3 pixel-связи в main 2026-06-11 00:24:36 +04:00
2559f1e66b Связи: финальный вид сияющих связей — плазма (поле+трубка+ядро, screen-blend) + цвет связи у обычных
Промежуточные итерации линий схлопнуты в один коммит. Убран мёртвый фильтр fg-plasma-turb. Лаборатория (/network-view/lab) и автотесты сохранены. Версия 1.2.158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:09:33 +03:00
519bce6b78 Связи (pixel-aquarium, 10.06): партия 3 (лаборатория) — общие связи + доступность
Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем).

- Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок
  + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана.
- Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня)
  для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label).

Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS.
Бамп client.version → 1.2.150.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:53:49 +03:00
557ea96be0 Связи (pixel-aquarium, 10.06): партия 2 (UI-фишки) — поиск, хлебные крошки, бейджи, цветовые кластеры
Всё в лаборатории (вариант 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>
2026-06-10 00:43:02 +03:00
9a49cc67f0 Связи (pixel-aquarium, 10.06): партия 1 (полиш) + автопроверки графа
Усиления (движок-полиш) с детерминированной самопроверкой:
- Веер детей — полукругом «наружу» (DEEP_FAN, по sibIndex от направления деда→родитель): не перекрывает
  нить-крошку и родителя; равномерное распределение.
- LOD с гистерезисом (LOD_ZOOM_UP=1.6 / DOWN=1.4) — точки 3-го уровня ↔ аватарки без «мигания» у порога.
- Двойной тап по пустому фону и сильный pinch-out на минимальном зуме = быстрый выход из погружения.
- Префетч аватарок детей при наведении/нырке (prefetchChildren) — лица в кэше до раскрытия.

Автопроверки (dev-only, ТОЛЬКО при ?fgtest):
- js/pages/network/selftest.js — 14 ассертов: камера-центровка, collision (нет слипания), полукруг,
  spotlight (путь 1.0 / фон 0.25 / сброс при переключении / 100% на выходе), LOD, возврат зума.
- Движок: read-only graph.debugState() + graph.pumpForTest() (синхронно докручивает кадры до покоя,
  не зависит от троттлинга rAF в фоне). Граф как window.__fg — тоже только при ?fgtest.
- Прогон: 14/14 PASS (offset 0px, мин.дистанция детей 89px, веер ±99°, LOD 4/4).

В обычной работе тест-хелперы не активны. Реальный путь /network-view не затронут. Бамп client → 1.2.148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:25:57 +03:00
3012f0799b Связи (pixel-aquarium, 10.06): фикс физики раскрытия + камера-наезд + железный spotlight
Исправлены критические баги «аквариума» по разбору (видео):

1. Слипание узлов → адаптивный радиус орбиты (фикс collision). Дети раскладываются на кольце
   ringR = max(baseR + радиус_родителя, число_детей×13): не лезут на (увеличенный зумом) родитель
   и друг на друга. Проверено: мин. дистанция 125px, 0 наложений (было — все в одной точке).

2. Умный наезд камеры на КЛИК по любому узлу (раньше 1-й уровень раскрывался на месте). diveTo
   центрирует узел (offset ~0), zoom 1.7; узел и дети растут до единого видимого размера
   (HERO_VISUAL/baseScaleOf, DIVE_CHILD_VISUAL) — крупно и читаемо. Наведение остаётся лёгким превью.

3. Железный Spotlight (единый активный путь): diveTo гасит ВСЕ прежние pin/hover, затем раскрывает
   только путь к цели. Открыто → путь=1.0, остальное=0.25; переключение веток сбрасывает прежнюю;
   exitDive/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает. (Проверено программно.)

Реальный путь /network-view не затронут (вся глубина под tier≥2/hasDeep). Бамп client.version → 1.2.147.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:12:27 +03:00
7a8852f64b Связи (pixel-aquarium): Умный фокус (Smart Zoom / «аквариум») — погружение в узел + LOD
Новая ветка для безопасного отката (от pixel-web). Режим «Вселенная», только лаборатория.

1. Гибрид клика: 1-й уровень → раскрытие ветки НА МЕСТЕ (как раньше); 2-й уровень+ → ПОГРУЖЕНИЕ.

2. Dive (умный наезд камеры, «аквариум», без перестройки графа):
   - diveTo(node): пинит весь путь (предки до Ивана), ставит diveTargetId + diveZoom=1.7;
     камера в tick плавно ЛЕТИТ и ЗУМИТ, центрируя узел (DIVE_FLY_K), узел ВЫРАСТАЕТ (×2.1 ~ герой).
   - Глубина (contextTargetOf → depthScale/depthBlur/spotCur, лерп): Иван и боковые ветки
     УМЕНЬШАЮТСЯ (root ×0.55) + уходят в BLUR 3px + тускнеют до 0.25 → задний план «аквариума».
   - Нить-крошка: путь Иван→…→узел (divePathSet/onPath) горит ярким «световодом» — виден путь назад.
   - Всплытие: повтор клика по цели → exitDive (камера/зум плавно к корню); клик по Ивану →
     collapseAll (полный сброс + всплытие).

3. Pinch-to-Zoom + LOD 3-го уровня: при zoom≥1.55 видимые точки 3-го уровня дорисовываются как
   читаемые аватарки (лицо+имя; updateLod/setNodeLod — пере-рендер DOM на пороге), при отдалении —
   обратно в светящиеся точки. Узлам tier-3 добавлены фото-заглушки (pravatar) и имена.

Глубина — фейк-3D через масштаб + CSS-blur (GPU), без WebGL. Реальный путь /network-view не затронут:
dive только tier≥2 (в реале их нет), depthScale/Blur нейтральны по умолчанию, updateLod выходит при !hasDeep.
Бамп client.version → 1.2.146.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:32:16 +03:00
f92e6c3cf1 Связи (pixel-web): фикс багов паутины — видимый 2-й уровень, удалён «прицел», toggle+spotlight
Исправлены замечания по видео (режим «Вселенная», только лаборатория):

1. «Невидимые друзья» (узлы 2-го уровня). Раньше — тусклые пустые кружки с инициалом.
   Теперь tier-2 — полноценные аватарки: фото-лицо (pravatar) + имя, DEEP2_SCALE 0.5→0.62
   (≈radius 16px), DEEP2_OPACITY 0.4→0.85; читаемый ободок и подпись (CSS).

2. Мусорный «прицел» (пунктирное кольцо у центра). Полностью удалён из движка:
   элемент .fg-reticle, updateReticle/pulseReticle и все их вызовы, CSS .fg-reticle*.
   На экране только аватарки и линии связей.

3. Логика toggle + spotlight:
   - Повторный клик по раскрытому узлу теперь СВОРАЧИВАЕТ его (isOpen = pinned || expandP>0.5
     → сброс pinned+hovered) — работает, даже если ветка была раскрыта ховером.
   - Spotlight: при закреплённой ветке остальные тускнеют до 0.25 (узлы и линии), фокус и
     закреплённая/наведённая ветка — 100%; плавно через spotCur (lerp, см. spotTargetOf).
   - Клик по центру (Иван) — collapseAll + возврат всему графу 100% яркости.

Реальный путь /network-view не затронут (deep-код под tier≥2/hasDeep). Ветка экспериментальная.
Бамп client.version → 1.2.145.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:55:10 +03:00
72dc83daff Связи (pixel-web): этап 2 паутины — hover-превью + collision + zoom + камера-доводчик + синхро-пульс
Доработка режима «Интерактивная паутина» (только лаборатория, deep-режим «Вселенная»):

Взаимодействие (по запросу): наведение ≠ клик.
- Hover-превью: навёл мышь/палец на узел — его ветка ВРЕМЕННО выплывает; убрал — втягивается.
  (pointerover/out для мыши, pointerdown/up для пальца → onNodeHover → graph.setHover; флаг hovered).
- Фиксация кликом: тап/клик → graph.toggleExpand ставит pinned — ветка остаётся раскрытой и
  после ухода курсора; повторный тап снимает фиксацию. Эффект = pinned || hovered (expandTargetOf).

Этап 2 «Мегамасштаб»:
- Collision-расталкивание: раскрытая ветка усиливает отталкивание соседей 1-го уровня
  пропорционально expandP (EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь.
- Свободный зум: колесо мыши (onWheel) + щипок двумя пальцами (activePointers/pinching),
  zoom 0.55–2.6 «к точке»; мир — CSS-scale, линии (SVG) пересчитываются в экранных координатах × zoom.
- Камера-доводчик: при фиксации ветки, если её веер упирается в край, камера мягко дотягивается
  (glideCameraTo → camTargetX/Y, lerp CAM_GLIDE_K в tick); любой жест отменяет доводчик.
- Синхро-пульс: сияющие/трековые «световоды» дышат толщиной/размытием 3.6с в такт ободку узла.

Реальный путь /network-view не затронут: deep-код под tier≥2/hasDeep, hover-колбэк даёт только
лаборатория. Ветка экспериментальная (отдельно от pixel-08.06/PR). Бамп client.version → 1.2.144.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:34:26 +03:00
04d9d588e8 Связи (pixel-web): режим «Интерактивная паутина» — раскрытие веток без смены центра
Этап 1 mind-map (только лаборатория, deep-режим «Вселенная»):
- Отмена прыжков в центр: тап по периферийному узлу больше НЕ перецентрирует — он остаётся
  на орбите, а из него раскрывается/сворачивается (toggle) его ветка дальних связей НА МЕСТЕ.
- Глобальный сброс: тап по корню (Иван) рекурсивно сворачивает все раскрытые ветки (collapseAll).
- Глубина скрыта по умолчанию; ветка плавно выплывает (expandP, ~400мс) и втягивается по повтору.
- Мерцающие звёзды 3-го уровня (CSS box-shadow/brightness, десинхрон по узлам) — «созвездие».
- Тактильный отклик navigator.vibrate(): клик при нажатии, серия импульсов на bloom-раскрытие,
  щелчок «гитарной струны» при сильном натяжении нитей свайпом.
- Движок: API toggleExpand/collapseAll; убрана press/hover-логика раскрытия (заменена тапом).

Ветка экспериментальная (отдельно от pixel-08.06/PR), бамп client.version → 1.2.143.
Ещё не сделано (следующие этапы): collision-расталкивание веток, камера-доводчик, zoom,
синхро-пульс линий к сияющим.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:04:48 +03:00
345a21a211 Связи (эксперимент pixel-web): база — микро-взаимодействия + глубина
Фиксирую накопленные черновики как точку отката перед режимом «Интерактивная паутина»:
- press/pan-bend (резиновые нити при свайпе, тактильное «вдавливание» узла);
- глубина 2-3 уровней (прототип «Вселенная», переключатель в лаборатории);
- прогрессивное раскрытие (глубина скрыта, выплывает по нажатию/наведению).

Ветка pixel-web — экспериментальная (отдельно от pixel-08.06/PR), чтобы можно было откатиться.
Бамп client.version → 1.2.142.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:51:48 +03:00
3de992d251 Связи: вернуть лабораторию в ветку (для просмотра графа на моках)
Откат удаления из aed64e7: lab.js снова в репозитории, статический импорт в network-view.js,
строка lab.js убрана из .gitignore. Лаборатория /network-view/lab нужна для демонстрации/проверки
графа на мок-данных без бэкенда. Бамп client.version → 1.2.141.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
369ef61cab Связи: лаборатория только локально (исключена из репозитория)
- shine-UI/js/pages/network/lab.js убран из git (git rm --cached) и добавлен в .gitignore:
  на сервере лаборатория не нужна (там реальные данные), локально файл остаётся и работает.
- network-view.js: статический импорт lab.js заменён на ДИНАМИЧЕСКИЙ с фолбэком — если файла
  нет (прод/сервер), реальный экран «Связи» не ломается, а заход на /network-view/lab уводит
  на обычный экран. Локально лаборатория грузится как прежде.
- Документация фичи отражает, что lab.js — локальный (в .gitignore).
- Бамп client.version → 1.2.140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
e6e96c4b0d Связи: чистка мёртвого кода в движке (lerp, неиспользуемые easing)
- Убран механизм lerpX/lerpY: координаты для отрисовки берутся из n.x/n.y, lerp нигде
  не читался кроме условия заморозки (lerpSettling). Удалены поля, advanceLerp(), EDGE_LERP
  и lerpSettling — граф засыпает чуть раньше (без визуальных изменений; проверено: frozen=true).
- Удалены неиспользуемые cubicBezier() и EASE_BLOOM (easing теперь делает CSS); easeOutCubic
  оставлен (нужен в stepTween для фолбэк-центрирования).
- Документация фичи актуализирована (убрана заметка про lerp как кандидата на чистку).
- Бамп client.version → 1.2.139.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:18 +03:00
dc96033cb1 Связи: двухслойные линии-световоды, живой фон и стеклянные фильтры
- Сияющие связи — двухслойный неоновый «световод»: размытый glow (4px, blur 2px,
  opacity 0.4) + тонкий чёткий core (1.5px, #e0f7fc). Объёмное OLED-свечение,
  линия остаётся изящной. Оба слоя растут синхронно (общий dashoffset).
- Обычные линии — тоньше (1.0–1.2px) и глубокий уход в прозрачность (0.42 → 0.07),
  чтобы матовые связи не спорили с сияющими.
- Живой фон-«небула»: глубокое размытое сине-голубое облако под центром, медленная
  пульсация радиуса/яркости + переливы индиго↔ультрамарин (hue-rotate, 7с).
- Стеклянные чипы фильтров (frosted glass): rgba(255,255,255,0.03) + backdrop blur(12px)
  + граница 0.5px solid rgba(255,255,255,0.1); активный подсвечен сине-голубым.
- Бамп client.version → 1.2.138; документация фичи обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:17 +03:00
9ee6bf4380 Связи: полировка карты связей (свечение, прорастание линий, CSS-фильтры)
- Линии: тонкие дуги Безье (градиент неон-центр → цвет роли); связь к «сияющему»
  монолитно светится статичной тенью drop-shadow (без бегущих импульсов).
- Прорастание новых линий из центра: stroke-dasharray/dashoffset синхронно с
  разлётом узла (кончик трекает аватарку); старые линии исчезают мгновенно.
- Ghost-слой: только аватарки (без линий), 1000мс — нет висящих «ошмётков».
- CSS-bloom разлёта на компоновщике (устойчив к троттлингу rAF; завершение по таймеру).
- Сияющие узлы: мягкая медленная пульсация 3.6с (многослойная box-shadow + SVG-ореол);
  тестовые фото-аватарки.
- Фильтры слоёв в лаборатории + фикс перехвата click сценой (stopPropagation на чипах);
  фейд скрываемых на месте (opacity 0 + scale 0.8, 300мс), фиксация без физики (ноль тряски).
- Бамп client.version → 1.2.137; обновлена документация фичи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:17 +03:00
e0f0726e68 Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами.

Движок (js/pages/network/force-graph.js):
- diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте);
- мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса;
- динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски;
- жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи;
- линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией;
- хард-лимит DOM-аватарок (остальное — SVG-точки).

Интеграция и UX:
- adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only);
- фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль;
- прицел в центре, дыхание фокуса, свечение сияющих;
- лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда.

Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md.
Бамп client.version 1.2.135 -> 1.2.136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:23:16 +03:00
2 changed files with 226 additions and 16 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.155
server.version=1.2.147
client.version=1.2.159
server.version=1.2.148

View File

@ -4,6 +4,10 @@
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально).
//
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
// Это чисто визуальный лабораторный эксперимент на мок-данных.
import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js';
@ -22,6 +26,18 @@ const FILTERS = {
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
function seed01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
function helpText() {
return [
'Лаборатория карты связей (мок-данные, без сервера).',
@ -30,6 +46,13 @@ function helpText() {
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
@ -42,6 +65,104 @@ function graphForLogin(login) {
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
}
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
function synthTier1(focusId) {
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
const out = [];
for (let i = 0; i < k; i += 1) {
const id = `${focusId}__t1_${i}`;
const s = seed01(id);
out.push({
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
avatar: null, photo: null,
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
connectionStrength: 0.5 + s * 0.4,
status: s > 0.78 ? 'shining' : '',
hasOwnConnections: true, tier: 1,
});
}
return out;
}
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
function addDeepLevels(model) {
const focusId = model.focusId;
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
const extra = [];
tier1.forEach((p) => {
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
const others = tier1.filter((o) => String(o.id) !== String(p.id));
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
if (i === 0 && common) {
extra.push({
id: id2, login: id2, name: common.name || common.login,
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
});
continue;
}
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
extra.push({
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
avatar: null, photo: face3, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
}
});
return { focusId, nodes: [...model.nodes, ...extra] };
}
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
function buildLabModel(login, deep, fromLogin) {
const tz = graphForLogin(login);
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
else tz.connections = [];
}
const base = buildModelFromTz(tz);
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
const fid = String(fromLogin);
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
if (found) {
found.track = true; // уже среди связей — просто подсветим трек
} else {
const f = graphForLogin(fromLogin).focusUser || {};
base.nodes.push({
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
photo: f.photo || null, relationType: 'friend', strength: 0.97,
shining: false, tier: 1, track: true,
});
}
}
return deep ? addDeepLevels(base) : base;
}
export function renderNetworkLab({ navigate }) {
const screen = document.createElement('section');
screen.className = 'network-screen';
@ -54,17 +175,13 @@ export function renderNetworkLab({ navigate }) {
const header = renderHeader({
title: 'Связи · лаборатория',
leftAction: {
label: '←',
onClick: () => navigate('network-view'),
},
rightActions: [
{ label: '?', onClick: () => window.alert(helpText()) },
],
leftAction: { label: '←', onClick: () => navigate('network-view') },
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
});
header.classList.add('network-header-overlay');
const model = buildModelFromTz(graphForLogin(START_LOGIN));
let centerLogin = START_LOGIN;
let deepMode = false;
// Состояние активного слоя (как в network-view): фокус всегда виден.
let activeFilter = 'all';
@ -82,18 +199,41 @@ export function renderNetworkLab({ navigate }) {
stage.append(header);
screen.append(stage);
const model = buildLabModel(centerLogin, deepMode);
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
stage,
model,
// тап по узлу — переключаем карту на сеть выбранного человека
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
graph.setModel(buildModelFromTz(graphForLogin(node.login)));
// сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
if (deepMode) {
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
graph.diveTo(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}`),
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
// Изменение пути погружения → перерисовываем хлебные крошки (Иван Нина Ада).
onDiveChange: (path) => renderBreadcrumb(path),
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,
@ -108,8 +248,7 @@ export function renderNetworkLab({ navigate }) {
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button');
@ -120,8 +259,79 @@ export function renderNetworkLab({ navigate }) {
filterChips[key] = chip;
filterBar.append(chip);
});
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
const deepChip = document.createElement('button');
deepChip.type = 'button';
deepChip.className = 'fg-filter-chip fg-deep-chip';
deepChip.textContent = '🌌 Вселенная';
deepChip.addEventListener('click', () => {
deepMode = !deepMode;
deepChip.classList.toggle('is-active', deepMode);
graph.setModel(buildLabModel(centerLogin, deepMode));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
});
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;
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
}
screen.cleanup = () => {
graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock');