// Лабораторный режим карты связей (network-view/lab). // // Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование, // центрирование и навигацию между пользователями. Используется связанный мульти-граф // networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает // карту на сеть этого человека (как реальный путь, но локально). import { renderHeader } from '../../components/header.js'; import { networkGraphUsers } from '../../mock-data.js'; import { createForceGraph, buildModelFromTz } from './force-graph.js'; 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 [ 'Лаборатория карты связей (мок-данные, без сервера).', '• Тащите по экрану — карта свободно перемещается (pan).', '• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.', '• Тап по центральному узлу — здесь открылся бы профиль.', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', '', 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', ].join('\n'); } // Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей). function graphForLogin(login) { const key = String(login || '').trim().toLowerCase(); if (networkGraphUsers[key]) return networkGraphUsers[key]; return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] }; } export function renderNetworkLab({ navigate }) { const screen = document.createElement('section'); screen.className = 'network-screen'; const appScreenEl = document.getElementById('app-screen'); appScreenEl?.classList.add('network-scroll-lock'); const stage = document.createElement('div'); stage.className = 'network-stage fg-stage'; const header = renderHeader({ title: 'Связи · лаборатория', leftAction: { label: '←', onClick: () => navigate('network-view'), }, rightActions: [ { label: '?', onClick: () => window.alert(helpText()) }, ], }); header.classList.add('network-header-overlay'); 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); // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. const graph = createForceGraph({ stage, model, // тап по узлу — переключаем карту на сеть выбранного человека 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, relationType: node.relationType, point, actions: [ { label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) }, { label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) }, ], }), }); // Панель фильтров слоёв — тот же оверлей-стиль (.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'); }; return screen; }