// Лабораторный режим карты связей (network-view/lab). // // Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование, // центрирование и навигацию между пользователями. Используется связанный мульти-граф // networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает // карту на сеть этого человека (как реальный путь, но локально). // // + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи // 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем). // Это чисто визуальный лабораторный эксперимент на мок-данных. 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']; // Пул имён для процедурных «дальних» узлов 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 [ 'Лаборатория карты связей (мок-данные, без сервера).', '• Тащите по экрану — карта свободно перемещается (pan).', '• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.', '• Тап по центральному узлу — здесь открылся бы профиль.', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', '• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).', ' Наведи мышь/палец на узел 1-го уровня — его микро-связи временно выплывают (превью);', ' клик/тап — раскрытие ФИКСИРУЕТСЯ. Клик по маленькому узлу 2-го уровня — «умный наезд»:', ' камера летит к нему, он вырастает в центр, Иван уходит вглубь (нить-крошка назад горит).', ' Клик по нему ещё раз — всплыть назад. Тап по центру (Ивану) — полный сброс.', ' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня', ' превращаются в аватарки. Свайп — 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: [] }; } // Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) — // синтезируем 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 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; // узлы 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'; 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'); let centerLogin = START_LOGIN; let deepMode = false; // Состояние активного слоя (как в 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); const model = buildLabModel(centerLogin, deepMode); // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. const graph = createForceGraph({ stage, model, // тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла); // в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита. onNodeTap: (node) => { if (deepMode) { // 2-й уровень+ → Smart Zoom (умный наезд камеры, «аквариум»): узел выплывает в центр, // Иван уходит вглубь, нить-крошка назад горит. 1-й уровень → раскрытие ветки НА МЕСТЕ. if ((node.tier || 1) >= 2) graph.diveTo(node); else graph.toggleExpand(node); return; } // обычный режим: перецентрирование на выбранного человека (+ трек прохождения) const from = centerLogin; centerLogin = node.login || node.id; graph.setModel(buildLabModel(centerLogin, deepMode, from)); if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); }, // Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором, // втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная». onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); }, 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, 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'; // Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» 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); }); // Переключатель прототипа «Вселенная» (глубина 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); screen.cleanup = () => { graph.destroy(); appScreenEl?.classList.remove('network-scroll-lock'); }; return screen; }