import { createTopBar } from '../components/topbar.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { authService, state } from '../state.js'; import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { createForceGraph } from './network/force-graph.js'; import { engineModelFromGraphModel } from './network/adapter.js'; import { openNodeMenu } from './network/node-menu.js'; import { userDisplayName } from '../services/user-display.js'; export const pageMeta = { id: 'network-view', title: 'Связи', shellMode: { topFade: true, bottomFade: true, bottomFadeAnchor: 'toolbar', fadeProfile: 'edge', contentUnderTopbar: true, scrollContainer: 'locked', }, }; const GENDER_MALE = 'male'; const GENDER_FEMALE = 'female'; const GENDER_UNKNOWN = 'unknown'; function normalizeLogin(value) { return String(value || '').trim(); } function createDebounced(fn, delayMs = 2000) { let timer = 0; return (...args) => { if (timer) window.clearTimeout(timer); timer = window.setTimeout(() => fn(...args), delayMs); }; } function normKey(value) { return normalizeLogin(value).toLowerCase(); } function uniqueLogins(list) { const out = []; const seen = new Set(); (Array.isArray(list) ? list : []).forEach((item) => { const login = normalizeLogin(item); if (!login) return; const key = normKey(login); if (seen.has(key)) return; seen.add(key); out.push(login); }); return out; } function escapeHtml(text) { return String(text || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function normalizeGender(value) { const clean = String(value || '').trim().toLowerCase(); if (clean === GENDER_MALE) return GENDER_MALE; if (clean === GENDER_FEMALE) return GENDER_FEMALE; return GENDER_UNKNOWN; } function toSet(list) { return new Set(uniqueLogins(list).map((value) => normKey(value))); } function hasLogin(setObj, login) { return setObj.has(normKey(login)); } function getMarkByLogin(allUsers) { const map = new Map(); (Array.isArray(allUsers) ? allUsers : []).forEach((row) => { const login = normalizeLogin(row?.login); if (!login) return; map.set(normKey(login), { login, firstName: String(row?.firstName || '').trim(), lastName: String(row?.lastName || '').trim(), displayName: userDisplayName({ login, firstName: row?.firstName, lastName: row?.lastName }), relationType: String(row?.relationType || '').trim().toLowerCase(), primaryConfirmed: Boolean(row?.primaryConfirmed), shineConfirmed: Boolean(row?.shineConfirmed), official: Boolean(row?.official), shine: Boolean(row?.shine), officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')), shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')), avatar: normalizeAvatar(row), }); }); return map; } function normalizeAvatar(row) { const txFromAvatar = String(row?.avatar?.ar || '').trim(); if (txFromAvatar) return { ar: txFromAvatar }; const txFallback = String(row?.avatarTxId || '').trim(); if (txFallback) return { ar: txFallback }; return null; } function applyRelativeGender(map, rows) { (Array.isArray(rows) ? rows : []).forEach((row) => { const login = normalizeLogin(row?.login); if (!login) return; const key = normKey(login); const gender = normalizeGender(row?.gender); const prev = map.get(key) || GENDER_UNKNOWN; if (prev === GENDER_UNKNOWN || gender !== GENDER_UNKNOWN) map.set(key, gender); }); } function getRelativeGenderMap(graph) { const map = new Map(); // Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды. void graph; return map; } function buildGraphModel(graph, centerLogin) { const login = normalizeLogin(graph?.login || centerLogin || state.session.login); const outFriends = toSet(graph?.outFriends); const inFriends = toSet(graph?.inFriends); const outCloseFriends = toSet(graph?.outCloseFriends); const inCloseFriends = toSet(graph?.inCloseFriends); const outParents = toSet(graph?.outParents); const inParents = toSet(graph?.inParents); const outChildren = toSet(graph?.outChildren); const inChildren = toSet(graph?.inChildren); const outSiblings = toSet(graph?.outSiblings); const inSiblings = toSet(graph?.inSiblings); const outSpouses = toSet(graph?.outSpouses); const inSpouses = toSet(graph?.inSpouses); // контакты/подписки/знакомые — для слоя «Все контакты» (Фаза 3) const outContacts = toSet(graph?.outContacts); const inContacts = toSet(graph?.inContacts); const outFollows = toSet(graph?.outFollows); const inFollows = toSet(graph?.inFollows); const outOfficial = toSet(graph?.outOfficialAccounts); const inOfficial = toSet(graph?.inOfficialAccounts); const relativesGender = getRelativeGenderMap(graph); const allMarks = getMarkByLogin(graph?.allUsers); const allLogins = uniqueLogins([ ...(graph?.outFriends || []), ...(graph?.inFriends || []), ...(graph?.outCloseFriends || []), ...(graph?.inCloseFriends || []), ...(graph?.outContacts || []), ...(graph?.inContacts || []), ...(graph?.outFollows || []), ...(graph?.inFollows || []), ...(graph?.outOfficialAccounts || []), ...(graph?.inOfficialAccounts || []), ]).filter((entry) => normKey(entry) !== normKey(login)); const relations = allLogins.map((targetLogin) => { const friendOut = hasLogin(outFriends, targetLogin); const friendIn = hasLogin(inFriends, targetLogin); const closeFriendOut = hasLogin(outCloseFriends, targetLogin); const closeFriendIn = hasLogin(inCloseFriends, targetLogin); const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outOfficial, targetLogin); const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inOfficial, targetLogin); let role = 'contact'; if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend'; let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut; let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn; return { login: targetLogin, key: normKey(targetLogin), role, isRelative: false, gender: normalizeGender(relativesGender.get(normKey(targetLogin))), forward: Boolean(forward), backward: Boolean(backward), mark: allMarks.get(normKey(targetLogin)) || null, }; }); return { centerLogin: login, centerMark: allMarks.get(normKey(login)) || null, relations, }; } let persistedCenterLogin = ''; let persistedCenterHistory = []; let persistedGraphHistory = []; const HISTORY_MAX_CENTERS = 4; const HISTORY_CENTER_GAP_MIN = 280; const HISTORY_CENTER_GAP_STEP = 72; const HISTORY_CENTER_CLEARANCE = 110; const HISTORY_ORBIT_FIRST_R = 104; const HISTORY_ORBIT_GAP = 88; const HISTORY_ORBIT_NODE_GAP = 84; const HISTORY_NODE_CLEARANCE = 92; function historyHash01(value) { let h = 2166136261; const text = String(value || ''); for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } return ((h >>> 0) % 1000) / 1000; } function historyOrbitCapacity(radius) { const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1); const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r))); return Math.max(1, Math.floor((Math.PI * 2) / minAngle)); } function historyOrbitPlacements(total, seed = '') { const count = Math.max(0, Number(total) || 0); const out = new Array(count); let start = 0; let ring = 0; const seedPhase = historyHash01(seed) * Math.PI * 2; while (start < count) { const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP; const capacity = historyOrbitCapacity(radius); const ringCount = Math.min(capacity, count - start); const phase = seedPhase + ring * 0.43; for (let i = 0; i < ringCount; i += 1) { out[start + i] = { radius, angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount), }; } start += ringCount; ring += 1; } return out; } function historyClusterRadius(snapshot) { const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : []; const centerKey = normKey(snapshot?.centerLogin); const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length; const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || ''); const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0); return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE; } function buildHistoryEngineModel(history) { const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS); const latest = snapshots[snapshots.length - 1]; if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; const centerPos = new Map(); const centerOrder = new Map(); const clusterRadius = new Map(); centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 }); snapshots.forEach((snap, index) => { const key = normKey(snap.centerLogin); centerOrder.set(key, index); clusterRadius.set(key, historyClusterRadius(snap)); }); // Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода // настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались. for (let i = snapshots.length - 1; i > 0; i -= 1) { const current = snapshots[i]; const previous = snapshots[i - 1]; const currentKey = normKey(current.centerLogin); const previousKey = normKey(previous.centerLogin); const curPos = centerPos.get(currentKey) || { x: 0, y: 0 }; const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0; const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE; const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE; let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR); let candidate = null; for (let attempt = 0; attempt < 24; attempt += 1) { candidate = { x: curPos.x - Math.cos(angle) * gap, y: curPos.y - Math.sin(angle) * gap, }; let collides = false; for (const [placedKey, placed] of centerPos.entries()) { const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE; const minDist = prevR + placedR; if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) { collides = true; break; } } if (!collides) break; gap += HISTORY_CENTER_GAP_STEP; } centerPos.set(previousKey, candidate || { x: curPos.x - Math.cos(angle) * gap, y: curPos.y - Math.sin(angle) * gap, }); } const nodeMap = new Map(); const latestPlacementParent = new Map(); const edgeMap = new Map(); // Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям. snapshots.forEach((snap, snapIndex) => { const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; const centerKey = normKey(snap.centerLogin); const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0]; if (center) { nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true }); } const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); relations.forEach((node) => { const key = normKey(node?.id); if (!key) return; nodeMap.set(key, { ...nodeMap.get(key), ...node }); const a = centerKey; const b = key; const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`; edgeMap.set(edgeKey, { a, b, relationType: node.relationType || 'contact', strength: Number(node.strength) || 0.5, snapIndex, }); }); }); // Все исторические центры сразу считаются занятыми местами. const occupied = []; for (const [centerKey, pos] of centerPos.entries()) { occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE }); } const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => { if (row.key === key) return false; return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); }); // Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь // встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге. for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) { const snap = snapshots[snapIndex]; const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : []; const centerKey = normKey(snap.centerLogin); const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey); const placements = historyOrbitPlacements(relations.length, snap.centerLogin); const base = centerPos.get(centerKey) || { x: 0, y: 0 }; relations.forEach((node, index) => { const key = normKey(node?.id); if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return; const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 }; let chosen = null; // Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус. for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) { const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55; const angularSamples = 18 + radialStep * 2; for (let step = 0; step < angularSamples; step += 1) { const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1); const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples); const x = base.x + Math.cos(angle) * radius; const y = base.y + Math.sin(angle) * radius; if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) { chosen = { x, y }; break; } } } if (!chosen) { const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55; chosen = { x: base.x + Math.cos(preferred.angle) * radius, y: base.y + Math.sin(preferred.angle) * radius, }; } latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex }); occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE }); }); } for (const [centerKey, pos] of centerPos.entries()) { const node = nodeMap.get(centerKey); if (!node) continue; nodeMap.set(centerKey, { ...node, layoutX: pos.x, layoutY: pos.y, fixedLayout: true, keepVisible: true, isHistoryCenter: true, }); } for (const [key, placement] of latestPlacementParent.entries()) { const node = nodeMap.get(key); if (!node || centerOrder.has(key)) continue; nodeMap.set(key, { ...node, layoutX: placement.x, layoutY: placement.y, fixedLayout: true, parentId: placement.centerKey, }); } const edgeParentsByChild = new Map(); const addEdgeParent = (childKey, parentKey, edge) => { if (!childKey || !parentKey || childKey === parentKey) return; const list = edgeParentsByChild.get(childKey) || []; if (!list.some((row) => row.id === parentKey)) { list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength, shining: edge.shining, }); } edgeParentsByChild.set(childKey, list); }; for (const edge of edgeMap.values()) { const aCenterIndex = centerOrder.get(edge.a); const bCenterIndex = centerOrder.get(edge.b); if (aCenterIndex !== undefined && bCenterIndex !== undefined) { if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge); else addEdgeParent(edge.b, edge.a, edge); continue; } if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge); else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge); } const nodes = [...nodeMap.entries()].map(([key, node]) => ({ ...node, id: key, login: node.login || node.id || key, tier: 1, fixedLayout: true, edgeParents: edgeParentsByChild.get(key) || [], })); return { focusId: normKey(latest.engineModel.focusId), nodes, preserveHistory: snapshots.length > 1, }; } export function render({ navigate, route, chrome } = {}) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; persistedGraphHistory = []; } const screen = document.createElement('section'); screen.className = 'network-screen'; const stage = document.createElement('div'); stage.className = 'network-stage'; const board = document.createElement('div'); board.className = 'network-board network-board--full fg-stage'; let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || ''); let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : []; let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : []; let engine = null; let loadSeq = 0; // Независимые фильтры карты. Оба выключены = показываем всё. // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. const FILTERS = { close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' }, shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) }, }; const FILTER_ORDER = ['close', 'shining']; const activeFilters = new Set(); const filterChips = {}; function currentFilterPredicate(node) { for (const key of activeFilters) { if (!FILTERS[key].pred(node)) return false; } return true; } function applyFilter(key) { if (!FILTERS[key]) return; if (activeFilters.has(key)) activeFilters.delete(key); else activeFilters.add(key); FILTER_ORDER.forEach((k) => { const el = filterChips[k]; if (el) el.classList.toggle('is-active', activeFilters.has(k)); }); if (engine) engine.setFilter(currentFilterPredicate); } function profileInfoRoute(login) { const cleanLogin = normalizeLogin(login); if (!cleanLogin) return ''; if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view'; return makeProfileRoute(cleanLogin); } function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; persistedGraphHistory = [...graphHistory]; } function syncLinksUrl(login, { push = false } = {}) { const clean = normalizeLogin(login); if (!clean) return; const nextPath = `/${makeProfileLinksRoute(clean)}`; if (window.location.pathname === nextPath) return; if (push) window.history.pushState({}, '', nextPath); else window.history.replaceState({}, '', nextPath); } function openSearchModal() { const root = document.getElementById('modal-root'); if (!(root instanceof HTMLElement)) return; root.innerHTML = ` `; const modal = root.querySelector('#network-search-modal'); const closeBtn = root.querySelector('#network-search-close'); const inputEl = root.querySelector('#network-search-input'); const runBtn = root.querySelector('#network-search-run'); const metaEl = root.querySelector('#network-search-meta'); const resultsEl = root.querySelector('#network-search-results'); if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) { root.innerHTML = ''; return; } let selectedLogin = ''; let searchSeq = 0; const close = () => { root.innerHTML = ''; }; const applySelection = (login) => { selectedLogin = normalizeLogin(login); const rows = resultsEl.querySelectorAll('[data-candidate]'); rows.forEach((row) => { if (!(row instanceof HTMLElement)) return; row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin); }); }; const renderCandidates = (logins) => { const items = (Array.isArray(logins) ? logins : []) .map((item) => normalizeLogin(item)) .filter(Boolean) .slice(0, 5); if (!items.length) { resultsEl.innerHTML = '
Кандидаты не найдены.
'; applySelection(''); return; } resultsEl.innerHTML = items.map((login) => ( `` )).join(''); applySelection(''); }; const runSearch = async () => { const query = normalizeLogin(inputEl.value); if (!query) { metaEl.textContent = 'Введите логин.'; renderCandidates([]); return; } const reqId = ++searchSeq; metaEl.textContent = `Поиск по «${query}»...`; if (runBtn instanceof HTMLButtonElement) runBtn.disabled = true; try { const found = await authService.searchUsers(query); if (reqId !== searchSeq) return; renderCandidates(found); const foundCount = Math.min(5, Array.isArray(found) ? found.length : 0); metaEl.textContent = foundCount > 0 ? `Найдено кандидатов: ${foundCount}. Выберите одного.` : 'Кандидаты не найдены.'; } catch (error) { if (reqId !== searchSeq) return; renderCandidates([]); metaEl.textContent = `Ошибка поиска: ${error?.message || 'unknown'}`; } finally { if (runBtn instanceof HTMLButtonElement) runBtn.disabled = false; } }; modal.addEventListener('click', (event) => { if (event.target === modal) close(); }); closeBtn?.addEventListener('click', close); runBtn?.addEventListener('click', () => { void runSearch(); }); const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000); inputEl.addEventListener('input', debouncedSearch); inputEl.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void runSearch(); } }); resultsEl.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; const button = target.closest('[data-candidate]'); if (!(button instanceof HTMLElement)) return; const nextLogin = String(button.dataset.candidate || ''); applySelection(nextLogin); if (!nextLogin) return; close(); void load(nextLogin, { pushHistory: true }); }); window.setTimeout(() => inputEl.focus(), 0); } function ensureEngine(model) { if (engine) { engine.setModel(model); return; } engine = createForceGraph({ stage: board, model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет onNodeTap: (node) => { const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0); void load(node.login, { pushHistory: true, transitionAngle }); }, // тап по центру — полноценный профиль onCenterTap: (node) => { const routeTo = profileInfoRoute(node.login); if (routeTo) navigate(routeTo); }, // долгое нажатие — контекстное меню (вне масштабируемого холста) onNodeLongPress: (node, point) => { const login = normalizeLogin(node.login); openNodeMenu({ login, displayName: String(node.name || '').trim(), relationType: node.relationType, point, actions: [ { label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } }, { label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(login)}`) }, ], }); }, }); } async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); try { const graph = await authService.getUserConnectionsGraph(targetCenter); if (requestId !== loadSeq) return; centerLogin = targetCenter; if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { centerHistory.push(prevCenter); } syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); const snapshotModel = engineModelFromGraphModel(graphModel); const snapshot = { centerLogin: targetCenter, engineModel: snapshotModel, transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, }; if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { // Один и тот же пользователь не хранится как два исторических центра: повторный переход // переносит его в конец истории, где он становится актуальным центром. graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter)); graphHistory.push(snapshot); if (graphHistory.length > HISTORY_MAX_CENTERS) { graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS); centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1)); } } else { const last = graphHistory[graphHistory.length - 1]; if (last && normKey(last.centerLogin) === normKey(targetCenter)) { snapshot.transitionAngle = Number(last.transitionAngle) || 0; graphHistory[graphHistory.length - 1] = snapshot; } else { graphHistory = [snapshot]; } } const engineModel = buildHistoryEngineModel(graphHistory); ensureEngine(engineModel); if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); } } const searchIconHtml = ` `; const header = createTopBar({ title: 'Связи', actions: [ { iconNode: createOverflowDots(), title: 'Меню связей', ariaLabel: 'Открыть меню связей', className: 'chat-header-icon-btn network-header-menu-btn', menu: { minWidth: 220, items: [ { label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal }, ], }, }, ], }); // Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри). screen.cleanup = () => { if (engine) engine.destroy(); engine = null; }; if (routeLogin) { centerLogin = routeLogin; centerHistory = []; graphHistory = []; persistHistory(); void load(centerLogin, { pushHistory: false }); } else if (keepHistory && centerLogin) { void load(centerLogin, { pushHistory: false }); } else { centerLogin = normalizeLogin(state.session.login || ''); centerHistory = []; graphHistory = []; persistHistory(); if (centerLogin) { void load(centerLogin, { pushHistory: false }); } else { window.setTimeout(() => openSearchModal(), 0); } } // Панель фильтров слоёв (оверлей под шапкой) const filterBar = document.createElement('div'); filterBar.className = 'fg-filter-bar app-top-tabs'; // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает 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${activeFilters.has(key) ? ' is-active' : ''}`; chip.textContent = FILTERS[key].label; chip.addEventListener('click', () => applyFilter(key)); filterChips[key] = chip; filterBar.append(chip); }); chrome?.setTopbar(header); stage.append(board, filterBar); screen.append(stage); return screen; }