import { renderHeader } from '../components/header.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, relationLabelRu } from './network/node-menu.js'; export const pageMeta = { id: 'network-view', title: 'Связи' }; 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, 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(); applyRelativeGender(map, graph?.parents); applyRelativeGender(map, graph?.children); applyRelativeGender(map, graph?.siblings); applyRelativeGender(map, graph?.spouses); 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 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 outKnown = toSet(graph?.outKnownPersons); const inKnown = toSet(graph?.inKnownPersons); const relativesGender = getRelativeGenderMap(graph); const allMarks = getMarkByLogin(graph?.allUsers); const allLogins = uniqueLogins([ ...(graph?.outFriends || []), ...(graph?.inFriends || []), ...(graph?.outParents || []), ...(graph?.inParents || []), ...(graph?.outChildren || []), ...(graph?.inChildren || []), ...(graph?.outSiblings || []), ...(graph?.inSiblings || []), ...(graph?.outSpouses || []), ...(graph?.inSpouses || []), ...(graph?.outContacts || []), ...(graph?.inContacts || []), ...(graph?.outFollows || []), ...(graph?.inFollows || []), ...(graph?.outKnownPersons || []), ...(graph?.inKnownPersons || []), ]).filter((entry) => normKey(entry) !== normKey(login)); const relations = allLogins.map((targetLogin) => { const parentOut = hasLogin(outParents, targetLogin); const parentIn = hasLogin(inChildren, targetLogin); const childOut = hasLogin(outChildren, targetLogin); const childIn = hasLogin(inParents, targetLogin); const siblingOut = hasLogin(outSiblings, targetLogin); const siblingIn = hasLogin(inSiblings, targetLogin); const spouseOut = hasLogin(outSpouses, targetLogin); const spouseIn = hasLogin(inSpouses, targetLogin); const friendOut = hasLogin(outFriends, targetLogin); const friendIn = hasLogin(inFriends, targetLogin); const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin); const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, targetLogin); let role = 'contact'; if (parentOut || parentIn) role = 'parent'; else if (childOut || childIn) role = 'child'; else if (spouseOut || spouseIn) role = 'spouse'; else if (siblingOut || siblingIn) role = 'sibling'; else if (friendOut || friendIn) role = 'friend'; let forward = friendOut; let backward = friendIn; if (role === 'parent') { forward = parentOut; backward = parentIn; } else if (role === 'child') { forward = childOut; backward = childIn; } else if (role === 'spouse') { forward = spouseOut; backward = spouseIn; } else if (role === 'sibling') { forward = siblingOut; backward = siblingIn; } else if (role === 'contact') { forward = contactOut; backward = contactIn; } return { login: targetLogin, key: normKey(targetLogin), role, isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling', 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 = []; export function render({ navigate, route }) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; } 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'; 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 engine = null; let sheetEl = null; let loadSeq = 0; // Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам. 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']; 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); }); if (engine) engine.setFilter(FILTERS[key].pred); } function profileInfoRoute(login) { const cleanLogin = normalizeLogin(login); if (!cleanLogin) return ''; if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view'; return makeProfileRoute(cleanLogin); } function helpText() { return [ 'Обозначения на экране связей:', '• Синие линии — близкие друзья.', '• Оранжевые линии — родственники.', '• Стрелка на линии — односторонняя связь.', '• Если стрелки нет, связь взаимная.', ].join('\n'); } function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; } 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 setBackButtonState(backBtn) { if (!(backBtn instanceof HTMLButtonElement)) return; backBtn.disabled = centerHistory.length === 0; } 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); } // Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту. function showNodeSheet(node) { if (!sheetEl) { sheetEl = document.createElement('div'); sheetEl.className = 'fg-sheet'; stage.append(sheetEl); } const login = normalizeLogin(node.login); const shineBadge = node.shining ? 'сияющий' : ''; sheetEl.innerHTML = `
${escapeHtml(login)} ${shineBadge}
${escapeHtml(relationLabelRu(node.relationType))}
`; sheetEl.classList.add('is-open'); sheetEl.onclick = (e) => { const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null; if (!(btn instanceof HTMLElement)) return; const act = btn.dataset.act; if (act === 'close') hideNodeSheet(); else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); } else if (act === 'write') navigate(`chat-view/${encodeURIComponent(login)}`); }; } function hideNodeSheet() { if (sheetEl) sheetEl.classList.remove('is-open'); } function ensureEngine(model) { if (engine) { engine.setModel(model); return; } engine = createForceGraph({ stage: board, model, // тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); }, // тап по центру — полноценный профиль onCenterTap: (node) => { const routeTo = profileInfoRoute(node.login); if (routeTo) navigate(routeTo); }, // долгое нажатие — контекстное меню (вне масштабируемого холста) onNodeLongPress: (node, point) => { const login = normalizeLogin(node.login); openNodeMenu({ login, relationType: node.relationType, point, actions: [ { label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } }, { label: 'Написать', onClick: () => navigate(`chat-view/${encodeURIComponent(login)}`) }, ], }); }, }); } async function load(nextCenterLogin = '', { pushHistory = false } = {}) { 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 engineModel = engineModelFromGraphModel(graphModel); ensureEngine(engineModel); // сохраняем выбранный фильтр при перестроении графа (центрирование/переход) if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred); persistHistory(); setBackButtonState(backBtnEl); } catch (error) { if (requestId !== loadSeq) return; window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`); } } const header = renderHeader({ title: 'Связи', leftAction: { label: '←', onClick: () => { if (!centerHistory.length) return; const prev = centerHistory.pop(); if (!prev) { setBackButtonState(backBtnEl); return; } void load(prev, { pushHistory: false }); }, }, rightActions: [ { label: 'Найти', onClick: openSearchModal }, { label: '?', onClick: () => window.alert(helpText()) }, ], }); const backBtnEl = header.querySelector('.header-left .icon-btn'); setBackButtonState(backBtnEl); // Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри). screen.cleanup = () => { if (engine) engine.destroy(); engine = null; appScreenEl?.classList.remove('network-scroll-lock'); }; if (routeLogin) { centerLogin = routeLogin; centerHistory = []; persistHistory(); void load(centerLogin, { pushHistory: false }); } else if (keepHistory && centerLogin) { void load(centerLogin, { pushHistory: false }); } else { centerLogin = normalizeLogin(state.session.login || ''); centerHistory = []; persistHistory(); if (centerLogin) { void load(centerLogin, { pushHistory: false }); } else { window.setTimeout(() => openSearchModal(), 0); } } setBackButtonState(backBtnEl); // Панель фильтров слоёв (оверлей под шапкой) 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); }); header.classList.add('network-header-overlay'); stage.append(board, header, filterBar); screen.append(stage); return screen; }