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), // Основной источник — server official. accountRole оставляем как совместимый fallback, // чтобы badge не пропадал на серверах/ответах переходного периода. official: row?.official === true || String(row?.official || '').trim().toLowerCase() === 'true' || String(row?.accountRole || '').trim().toLowerCase() === 'primary', shine: row?.shine === true || String(row?.shine || '').trim().toLowerCase() === 'true' || String(row?.shineStatus || '').trim().toLowerCase() === 'shining', 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 = []; let persistedHistoryDepth = 4; let persistedX2Enabled = false; const HISTORY_MAX_PREVIOUS = 4; const HISTORY_CENTER_GAP_START = 108; const HISTORY_CENTER_GAP_STEP = 28; const HISTORY_CENTER_CLEARANCE = 88; const HISTORY_NODE_CLEARANCE = 82; const HISTORY_TIER2_CLEARANCE = 48; const HISTORY_DIRECT_SPACING = 92; const HISTORY_TIER2_SPACING = 54; const HISTORY_LAYOUT_MAX_SHELL = 9; 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) % 100000) / 100000; } function historySquareShellCells(shell) { const r = Math.max(1, Math.trunc(shell)); const cells = []; for (let y = -r; y <= r; y += 1) { for (let x = -r; x <= r; x += 1) { if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue; cells.push({ x, y }); } } return cells; } // Компактная «квадратная» раскладка: сначала заполняем ближайший квадратный пояс, // причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей // не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра. function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) { const count = Math.max(0, Math.trunc(Number(total) || 0)); const out = []; let globalIndex = 0; for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) { const remaining = historySquareShellCells(shell); const ordered = []; const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0; if (remaining.length) ordered.push(remaining.splice(start, 1)[0]); while (remaining.length) { let bestIndex = 0; let bestScore = -Infinity; remaining.forEach((cell, index) => { let minD2 = Infinity; for (const used of ordered) { const dx = cell.x - used.x; const dy = cell.y - used.y; minD2 = Math.min(minD2, dx * dx + dy * dy); } const jitter = historyHash01(`${seed}|${shell}|${cell.x}|${cell.y}`) * 0.05; const score = minD2 + jitter; if (score > bestScore) { bestScore = score; bestIndex = index; } }); ordered.push(remaining.splice(bestIndex, 1)[0]); } const quarterTurns = Math.floor(historyHash01(`${seed}|rotate`) * 4); const turn = (cell) => { let { x, y } = cell; for (let i = 0; i < quarterTurns; i += 1) [x, y] = [-y, x]; return { x, y }; }; for (const raw of ordered) { if (out.length >= count) break; const cell = turn(raw); const jx = (historyHash01(`${seed}|${globalIndex}|x`) - 0.5) * spacing * 0.10; const jy = (historyHash01(`${seed}|${globalIndex}|y`) - 0.5) * spacing * 0.10; out.push({ x: cell.x * spacing + jx, y: cell.y * spacing + jy, shell, }); globalIndex += 1; } } return out; } function historyIdealShell(count) { const n = Math.max(0, Math.trunc(Number(count) || 0)); if (!n) return 0; let shell = 1; while (4 * shell * (shell + 1) < n) shell += 1; return shell; } function relationEdge(parentId, node) { return { id: normKey(parentId), relationType: String(node?.relationType || 'contact'), strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)), }; } function stripSecondLevel(model) { const nodes = Array.isArray(model?.nodes) ? model.nodes : []; const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id))); return { ...model, nodes: nodes .filter((node) => keep.has(normKey(node?.id))) .map((node) => ({ ...node, edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []) .filter((edge) => keep.has(normKey(edge?.id))), })), }; } async function mapWithConcurrency(items, concurrency, worker) { const list = Array.isArray(items) ? items : []; const out = new Array(list.length); let cursor = 0; const runners = new Array(Math.min(Math.max(1, concurrency), list.length)).fill(0).map(async () => { while (cursor < list.length) { const index = cursor++; try { out[index] = await worker(list[index], index); } catch (error) { out[index] = { error }; } } }); await Promise.all(runners); return out; } async function buildSecondLevelEngineModel(baseModel, getGraph) { const focusKey = normKey(baseModel?.focusId); const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : []; const byKey = new Map(); baseNodes.forEach((src) => { const key = normKey(src?.id); if (!key) return; const isFocus = key === focusKey; const normalized = { ...src, id: key, login: src?.login || src?.id || key, tier: 1, parentId: isFocus ? '' : focusKey, edgeParents: isFocus ? [] : [relationEdge(focusKey, src)], }; byKey.set(key, normalized); }); const directFriends = [...byKey.values()].filter((node) => ( normKey(node.id) !== focusKey && (node.relationType === 'friend' || node.relationType === 'close_friend') )); const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => { const graph = await getGraph(parent.login || parent.id); const graphModel = buildGraphModel(graph, parent.login || parent.id); return { parent, model: engineModelFromGraphModel(graphModel) }; }); fetched.forEach((row) => { if (!row || row.error || !row.model) return; const parentKey = normKey(row.parent?.id); const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : []; childNodes.forEach((child) => { const childKey = normKey(child?.id); if (!childKey || childKey === parentKey || childKey === focusKey) return; if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return; const edge = relationEdge(parentKey, child); const existing = byKey.get(childKey); if (existing) { const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : []; if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge); byKey.set(childKey, { ...existing, edgeParents: refs }); return; } byKey.set(childKey, { ...child, id: childKey, login: child?.login || child?.id || childKey, tier: 2, parentId: parentKey, alwaysVisible: true, edgeParents: [edge], }); }); }); return { ...baseModel, nodes: [...byKey.values()] }; } function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); const maxSnapshots = keepPrevious + 1; const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots); const latest = snapshots[snapshots.length - 1]; if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] }; const centerOrder = new Map(); const nodeMap = new Map(); const latestOwner = new Map(); const edgeMap = new Map(); snapshots.forEach((snap, snapIndex) => { const centerKey = normKey(snap?.centerLogin); if (!centerKey) return; centerOrder.set(centerKey, snapIndex); const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; modelNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; const node = { ...nodeMap.get(key), ...rawNode, id: key, login: rawNode?.login || rawNode?.id || key, }; if (key === centerKey) { node.tier = 1; node.keepVisible = true; node.isHistoryCenter = true; } nodeMap.set(key, node); latestOwner.set(key, snapIndex); if (key === centerKey) return; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)]; parents.forEach((ref) => { const parentKey = normKey(ref?.id || centerKey); if (!parentKey || parentKey === key) return; const a = parentKey < key ? parentKey : key; const b = parentKey < key ? key : parentKey; edgeMap.set(`${a}|${b}`, { parent: parentKey, child: key, relationType: String(ref?.relationType || rawNode?.relationType || 'contact'), strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)), snapIndex, }); }); }); }); const occupied = []; const placed = new Map(); const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => { if (ignoreKey && row.key === ignoreKey) return false; return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0); }); function ownedNodesForSnapshot(snapIndex) { const centerKey = normKey(snapshots[snapIndex]?.centerLogin); return [...nodeMap.entries()] .filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key)) .map(([key, node]) => ({ key, node })); } function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) { const snap = snapshots[snapIndex]; const centerKey = normKey(snap?.centerLogin); const localPlaced = new Map([[centerKey, { ...center }]]); const rows = baseOccupied.map((row) => ({ ...row })); if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) { return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows }; } rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE }); const owned = ownedNodesForSnapshot(snapIndex); const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2); const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2); const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING); let maxDirectShell = 0; direct.forEach(({ key, node }) => { let chosen = null; const clearance = clearanceForNode(node); for (const slot of directSlots) { const x = center.x + slot.x; const y = center.y + slot.y; if (!collides(x, y, clearance, rows, key)) { chosen = { x, y, shell: slot.shell }; break; } } if (!chosen) { const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2; const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1); chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 }; } maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0); localPlaced.set(key, chosen); rows.push({ key, x: chosen.x, y: chosen.y, clearance }); }); const deepByParent = new Map(); deep.forEach((row) => { const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey); const list = deepByParent.get(parentKey) || []; list.push(row); deepByParent.set(parentKey, list); }); for (const [parentKey, children] of deepByParent.entries()) { const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center; const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6); children.forEach(({ key, node }) => { let chosen = null; const clearance = clearanceForNode(node); for (const slot of slots) { const x = parentPos.x + slot.x; const y = parentPos.y + slot.y; if (!collides(x, y, clearance, rows, key)) { chosen = { x, y, shell: slot.shell }; break; } } if (!chosen) { const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2; const radius = HISTORY_TIER2_SPACING * 7; chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 }; } localPlaced.set(key, chosen); rows.push({ key, x: chosen.x, y: chosen.y, clearance }); }); } const idealDirectShell = historyIdealShell(direct.length); const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1); if (commit) { localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y })); occupied.splice(0, occupied.length, ...rows); } return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows }; } // Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно. const latestIndex = snapshots.length - 1; tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true); // Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум: // сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем // наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap. // Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку. const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2]; const compactnessScore = (rows, desiredAngleOffset = 0) => { if (!rows.length) return 0; let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; rows.forEach((row) => { const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE; minX = Math.min(minX, row.x - c * 0.5); maxX = Math.max(maxX, row.x + c * 0.5); minY = Math.min(minY, row.y - c * 0.5); maxY = Math.max(maxY, row.y + c * 0.5); }); const width = Math.max(1, maxX - minX); const height = Math.max(1, maxY - minY); const area = width * height; const aspectPenalty = Math.abs(Math.log(width / height)); // Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход // от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот. return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600; }; for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) { const newer = snapshots[snapIndex + 1]; const newerKey = normKey(newer?.centerLogin); const newerPos = placed.get(newerKey) || { x: 0, y: 0 }; const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0; let gap = HISTORY_CENTER_GAP_START; let best = null; for (let attempt = 0; attempt < 40; attempt += 1) { const candidates = []; angleOffsets.forEach((offset) => { const angle = baseAngle + offset; const candidate = { x: newerPos.x - Math.cos(angle) * gap, y: newerPos.y - Math.sin(angle) * gap, }; const trial = tryPlaceCluster(snapIndex, candidate, occupied, false); if (!trial.ok) return; candidates.push({ candidate, offset, score: compactnessScore(trial.rows, offset), }); }); if (candidates.length) { candidates.sort((a, b) => a.score - b.score); best = candidates[0]; break; } gap += HISTORY_CENTER_GAP_STEP; } const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8); const candidate = best?.candidate || { x: newerPos.x - Math.cos(fallbackAngle) * gap, y: newerPos.y - Math.sin(fallbackAngle) * gap, }; tryPlaceCluster(snapIndex, candidate, occupied, true); } // На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot. snapshots.forEach((snap) => { const key = normKey(snap?.centerLogin); if (!placed.has(key)) placed.set(key, { x: 0, y: 0 }); }); const edgeParentsByChild = new Map(); const addEdgeParent = (childKey, parentKey, edge) => { if (!childKey || !parentKey || childKey === parentKey) return; const list = edgeParentsByChild.get(childKey) || []; if (!list.some((row) => normKey(row?.id) === parentKey)) { list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength }); } edgeParentsByChild.set(childKey, list); }; for (const edge of edgeMap.values()) { if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; addEdgeParent(edge.child, edge.parent, edge); } const nodes = [...nodeMap.entries()].map(([key, node]) => { const pos = placed.get(key) || { x: 0, y: 0 }; const isCenter = centerOrder.has(key); return { ...node, id: key, login: node.login || node.id || key, tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1), layoutX: pos.x, layoutY: pos.y, fixedLayout: true, keepVisible: isCenter || Boolean(node.keepVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), 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 historyDepth = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Number(persistedHistoryDepth) || 0)); let x2Enabled = Boolean(persistedX2Enabled); let engine = null; let loadSeq = 0; const graphCache = new Map(); let historyChip = null; let x2Chip = null; // Независимые фильтры карты. Оба выключены = показываем всё. // Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей. 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]; persistedHistoryDepth = historyDepth; persistedX2Enabled = x2Enabled; } function rebuildEngineFromHistory() { const engineModel = buildHistoryEngineModel(graphHistory, historyDepth); ensureEngine(engineModel); if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate); } function updateHistoryChip() { if (!(historyChip instanceof HTMLButtonElement)) return; historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История'; historyChip.classList.toggle('is-active', historyDepth > 0); historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false'); historyChip.title = historyDepth > 0 ? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.` : 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.'; } function cycleHistoryDepth() { historyDepth = historyDepth >= HISTORY_MAX_PREVIOUS ? 0 : historyDepth + 1; const maxSnapshots = historyDepth + 1; graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1); centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : []; updateHistoryChip(); rebuildEngineFromHistory(); persistHistory(); } function updateX2Chip() { if (!(x2Chip instanceof HTMLButtonElement)) return; x2Chip.classList.toggle('is-active', x2Enabled); x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false'); x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.'; } async function toggleX2() { x2Enabled = !x2Enabled; updateX2Chip(); persistedX2Enabled = x2Enabled; await load(centerLogin, { pushHistory: false }); } 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 = `