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_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 relationEdge(parentId, node) { return { id: normKey(parentId), relationType: String(node?.relationType || 'contact'), strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)), }; } 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 : []; // ФАЗА 1. Сначала фиксируем ПОЛНЫЙ первый уровень центрального пользователя. // Его уровень больше никогда не зависит от того, в каком из ответов друзей он встретится позднее. const byKey = new Map(); const firstLevelKeys = new Set(); baseNodes.forEach((src) => { const key = normKey(src?.id); if (!key) return; const isFocus = key === focusKey; if (!isFocus && src?.relationType !== 'friend' && src?.relationType !== 'close_friend') return; 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); if (!isFocus) firstLevelKeys.add(key); }); const directFriends = [...firstLevelKeys] .map((key) => byKey.get(key)) .filter(Boolean); // ФАЗА 2. Запрашиваем КАЖДОГО друга первого уровня. Пока все ответы не получены, // структуру X2 не достраиваем и в движок ничего не отдаём. 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) }; }); const failedParents = fetched .map((row, index) => row?.error ? directFriends[index] : null) .filter(Boolean); if (failedParents.length) { const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', '); throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`); } // ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра. // Сначала накапливаем, потом одним проходом присваиваем глубину. depth=1 всегда приоритетнее depth=2. const secondCandidates = new Map(); const edgesByChild = new Map(); fetched.forEach((row) => { if (!row || !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 refs = edgesByChild.get(childKey) || []; if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(relationEdge(parentKey, child)); edgesByChild.set(childKey, refs); // Если это прямой друг центра, его не переносим на второй уровень — только добавляем новое ребро. if (firstLevelKeys.has(childKey)) return; if (!secondCandidates.has(childKey)) secondCandidates.set(childKey, child); }); }); // ФАЗА 4. Только теперь собираем окончательные узлы. firstLevelKeys.forEach((key) => { const existing = byKey.get(key); if (!existing) return; const extra = edgesByChild.get(key) || []; const refs = [...(Array.isArray(existing.edgeParents) ? existing.edgeParents : [])]; extra.forEach((edge) => { if (!refs.some((ref) => normKey(ref?.id) === normKey(edge?.id))) refs.push(edge); }); byKey.set(key, { ...existing, tier: 1, edgeParents: refs }); }); secondCandidates.forEach((child, childKey) => { const refs = edgesByChild.get(childKey) || []; const parentKey = normKey(refs[0]?.id); byKey.set(childKey, { ...child, id: childKey, login: child?.login || child?.id || childKey, tier: 2, parentId: parentKey, alwaysVisible: true, edgeParents: refs, }); }); return { ...baseModel, nodes: [...byKey.values()] }; } const NETWORK_GRAPH_CACHE_TTL_MS = 2 * 60 * 1000; const NETWORK_GRAPH_CACHE_MAX = 240; const networkGraphCache = new Map(); const networkGraphInflight = new Map(); async function getConnectionsGraphCached(login, { force = false, retries = 0 } = {}) { const clean = normalizeLogin(login); const key = normKey(clean); if (!key) throw new Error('LOGIN_REQUIRED'); const now = Date.now(); const cached = networkGraphCache.get(key); if (!force && cached && now - cached.savedAt < NETWORK_GRAPH_CACHE_TTL_MS) return cached.graph; if (!force && networkGraphInflight.has(key)) return networkGraphInflight.get(key); const request = (async () => { let lastError = null; for (let attempt = 0; attempt <= Math.max(0, retries); attempt += 1) { try { const graph = await authService.getUserConnectionsGraph(clean); networkGraphCache.delete(key); networkGraphCache.set(key, { graph, savedAt: Date.now() }); while (networkGraphCache.size > NETWORK_GRAPH_CACHE_MAX) { const oldestKey = networkGraphCache.keys().next().value; if (!oldestKey) break; networkGraphCache.delete(oldestKey); } return graph; } catch (error) { lastError = error; if (attempt < retries) await new Promise((resolve) => window.setTimeout(resolve, 120 * (attempt + 1))); } } throw lastError || new Error('GRAPH_LOAD_FAILED'); })(); networkGraphInflight.set(key, request); try { return await request; } finally { if (networkGraphInflight.get(key) === request) networkGraphInflight.delete(key); } } function historyNodeClearance(node) { return (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE; } function cloneEngineModel(model) { return { ...model, nodes: (Array.isArray(model?.nodes) ? model.nodes : []).map((node) => ({ ...node, edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : []).map((edge) => ({ ...edge })), })), }; } function modelNodePositions(model) { const out = new Map(); (Array.isArray(model?.nodes) ? model.nodes : []).forEach((node) => { const key = normKey(node?.id); const x = Number(node?.layoutX); const y = Number(node?.layoutY); if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; out.set(key, { x, y, clearance: historyNodeClearance(node), node }); }); return out; } function translateSnapshot(snapshot, dx, dy) { const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); model.nodes = model.nodes.map((node) => { const x = Number(node?.layoutX); const y = Number(node?.layoutY); if (!Number.isFinite(x) || !Number.isFinite(y)) return node; return { ...node, layoutX: x + dx, layoutY: y + dy, fixedLayout: true }; }); return { ...snapshot, engineModel: model }; } function placeCompactNodes(nodes, { center = { x: 0, y: 0 }, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL, occupied = [], } = {}) { const rows = occupied.map((row) => ({ ...row })); const positions = new Map(); const slots = historyCompactSlots(Math.max(nodes.length + 48, 96), seed, spacing, maxShell); nodes.forEach((node, index) => { const key = normKey(node?.id); if (!key) return; const clearance = historyNodeClearance(node); let chosen = null; for (const slot of slots) { const x = center.x + slot.x; const y = center.y + slot.y; const collision = rows.some((row) => Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0)); if (!collision) { chosen = { x, y }; break; } } if (!chosen) { const angle = historyHash01(`${seed}|${key}|fallback`) * Math.PI * 2; const radius = spacing * (maxShell + 1 + Math.floor(index / 8)); chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }; } positions.set(key, chosen); rows.push({ key, x: chosen.x, y: chosen.y, clearance }); }); return { positions, occupied: rows }; } function layoutFirstLevelEngineModel(model, seed = '', { skipKeys = new Set() } = {}) { const out = cloneEngineModel(model); const focusKey = normKey(out?.focusId); const nodes = Array.isArray(out.nodes) ? out.nodes : []; const focus = nodes.find((node) => normKey(node?.id) === focusKey); const peers = nodes.filter((node) => { const key = normKey(node?.id); return key !== focusKey && !skipKeys.has(key) && (Number(node?.tier) || 1) < 2; }); const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; const placed = placeCompactNodes(peers, { center: { x: 0, y: 0 }, seed: `${seed}|tier1`, occupied }); out.nodes = nodes.map((node) => { const key = normKey(node?.id); if (key === focusKey) { return { ...node, id: key, layoutX: 0, layoutY: 0, fixedLayout: true, tier: 1, keepVisible: true }; } if (skipKeys.has(key)) { const clean = { ...node, id: key, parentId: normKey(node?.parentId || focusKey) }; delete clean.layoutX; delete clean.layoutY; clean.fixedLayout = false; return clean; } const pos = placed.positions.get(key) || { x: 0, y: 0 }; return { ...node, id: key, parentId: normKey(node?.parentId || focusKey), layoutX: pos.x, layoutY: pos.y, fixedLayout: true, }; }); return out; } function layoutX2EngineModel(model, seed = '') { const out = cloneEngineModel(model); const focusKey = normKey(out?.focusId); const nodes = Array.isArray(out.nodes) ? out.nodes : []; const direct = nodes.filter((node) => normKey(node?.id) !== focusKey && (Number(node?.tier) || 1) === 1); const deep = nodes.filter((node) => (Number(node?.tier) || 1) >= 2); const childCount = new Map(); deep.forEach((node) => { const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id); if (!parentKey) return; childCount.set(parentKey, (childCount.get(parentKey) || 0) + 1); }); const positions = new Map([[focusKey, { x: 0, y: 0 }]]); const zones = [{ key: focusKey, x: 0, y: 0, radius: 72 }]; const candidates = historyCompactSlots(Math.max(240, direct.length * 14), `${seed}|x2parents`, 92, 18); direct.forEach((node, index) => { const key = normKey(node?.id); const count = childCount.get(key) || 0; const zoneRadius = Math.max(54, 48 + Math.ceil(Math.sqrt(count)) * 24); let chosen = null; for (const slot of candidates) { const x = slot.x; const y = slot.y; const collision = zones.some((zone) => Math.hypot(x - zone.x, y - zone.y) < zoneRadius + zone.radius + 10); if (!collision) { chosen = { x, y }; break; } } if (!chosen) { const angle = historyHash01(`${seed}|x2parent|${key}`) * Math.PI * 2; const radius = 180 + index * 34; chosen = { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; } positions.set(key, chosen); zones.push({ key, x: chosen.x, y: chosen.y, radius: zoneRadius }); }); const occupied = [{ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }]; direct.forEach((node) => { const key = normKey(node?.id); const pos = positions.get(key); if (pos) occupied.push({ key, x: pos.x, y: pos.y, clearance: HISTORY_NODE_CLEARANCE }); }); const deepByParent = new Map(); deep.forEach((node) => { const parentKey = normKey(node?.parentId || node?.edgeParents?.[0]?.id || focusKey); const rows = deepByParent.get(parentKey) || []; rows.push(node); deepByParent.set(parentKey, rows); }); for (const [parentKey, children] of deepByParent.entries()) { const parentPos = positions.get(parentKey) || { x: 0, y: 0 }; const placed = placeCompactNodes(children, { center: parentPos, seed: `${seed}|x2children|${parentKey}`, spacing: HISTORY_TIER2_SPACING, maxShell: 12, occupied, }); placed.positions.forEach((pos, key) => positions.set(key, pos)); occupied.splice(0, occupied.length, ...placed.occupied); } out.nodes = nodes.map((node) => { const key = normKey(node?.id); const pos = positions.get(key) || { x: 0, y: 0 }; return { ...node, id: key, tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), layoutX: pos.x, layoutY: pos.y, fixedLayout: true, keepVisible: key === focusKey || Boolean(node?.keepVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), }; }); return out; } function refreshFirstLevelSnapshot(previousSnapshot, nextEngineModel, seed = '') { const previousPositions = modelNodePositions(previousSnapshot?.engineModel); if (!previousPositions.size) return layoutFirstLevelEngineModel(nextEngineModel, seed); const out = cloneEngineModel(nextEngineModel); const focusKey = normKey(out?.focusId); const keepKeys = new Set((Array.isArray(out.nodes) ? out.nodes : []).map((node) => normKey(node?.id)).filter(Boolean)); const occupied = []; const positions = new Map(); previousPositions.forEach((row, key) => { if (!keepKeys.has(key)) return; positions.set(key, { x: row.x, y: row.y }); occupied.push({ key, x: row.x, y: row.y, clearance: row.clearance || HISTORY_NODE_CLEARANCE }); }); if (!positions.has(focusKey)) { positions.set(focusKey, { x: 0, y: 0 }); occupied.push({ key: focusKey, x: 0, y: 0, clearance: HISTORY_CENTER_CLEARANCE }); } const newNodes = (Array.isArray(out.nodes) ? out.nodes : []).filter((node) => { const key = normKey(node?.id); return key && key !== focusKey && !positions.has(key) && (Number(node?.tier) || 1) < 2; }); const placed = placeCompactNodes(newNodes, { center: positions.get(focusKey) || { x: 0, y: 0 }, seed: `${seed}|refresh`, occupied }); placed.positions.forEach((pos, key) => positions.set(key, pos)); out.nodes = out.nodes.map((node) => { const key = normKey(node?.id); const pos = positions.get(key) || { x: 0, y: 0 }; return { ...node, id: key, parentId: key === focusKey ? '' : normKey(node?.parentId || focusKey), layoutX: pos.x, layoutY: pos.y, fixedLayout: true, tier: key === focusKey ? 1 : Math.max(1, Number(node?.tier) || 1), keepVisible: key === focusKey || Boolean(node?.keepVisible), }; }); return out; } function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) { const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0))); const snapshots = (Array.isArray(history) ? history : []).slice(-(keepPrevious + 1)); const latest = snapshots[snapshots.length - 1]; if (!latest?.engineModel) return { focusId: '', nodes: [] }; const centerKeys = new Set(snapshots.map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); const latestCenterKey = normKey(latest.centerLogin); const centerNodeByKey = new Map(); const nodeMap = new Map(); const edgeMap = new Map(); snapshots.forEach((snap) => { const centerKey = normKey(snap?.centerLogin); const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); snapNodes.forEach((rawNode) => { const key = normKey(rawNode?.id); if (!key) return; // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); 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)), }); }); }); }); centerNodeByKey.forEach((node, key) => { if (key !== latestCenterKey) nodeMap.set(key, { ...node, id: key }); }); const edgeParentsByChild = new Map(); for (const edge of edgeMap.values()) { if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue; const list = edgeParentsByChild.get(edge.child) || []; if (!list.some((row) => normKey(row?.id) === edge.parent)) { list.push({ id: edge.parent, relationType: edge.relationType, strength: edge.strength }); } edgeParentsByChild.set(edge.child, list); } const nodes = [...nodeMap.entries()].map(([key, node]) => ({ ...node, id: key, login: node?.login || node?.id || key, tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), fixedLayout: true, keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), edgeParents: edgeParentsByChild.get(key) || [], })); return { focusId: latestCenterKey, nodes, preserveHistory: snapshots.length > 1, }; } function historyExistingCenterKeys(history) { return new Set((Array.isArray(history) ? history : []).map((snap) => normKey(snap?.centerLogin)).filter(Boolean)); } function chooseNewClusterCenter(history, newLocalModel, { transitionAngle = 0, transitionX = 0, transitionY = 0 } = {}) { const currentModel = buildStableHistoryEngineModel(history, HISTORY_MAX_PREVIOUS); const existing = modelNodePositions(currentModel); const existingCenters = historyExistingCenterKeys(history); const focusKey = normKey(newLocalModel?.focusId); const localPositions = modelNodePositions(newLocalModel); const movingKeys = new Set([...localPositions.keys()].filter((key) => !existingCenters.has(key) || key === focusKey)); const blockers = [...existing.entries()] .filter(([key]) => !movingKeys.has(key)) .map(([key, row]) => ({ key, ...row })); const directDistance = Math.hypot(Number(transitionX) || 0, Number(transitionY) || 0); let radius = Math.max(104, directDistance + 24); const baseAngle = Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0; const offsets = [0, Math.PI / 6, -Math.PI / 6, Math.PI / 3, -Math.PI / 3, Math.PI / 2, -Math.PI / 2, Math.PI]; for (let attempt = 0; attempt < 56; attempt += 1) { let best = null; offsets.forEach((offset) => { const angle = baseAngle + offset; const cx = Math.cos(angle) * radius; const cy = Math.sin(angle) * radius; let ok = true; for (const [key, row] of localPositions.entries()) { if (!movingKeys.has(key)) continue; const x = cx + row.x; const y = cy + row.y; const hit = blockers.some((blocker) => Math.hypot(x - blocker.x, y - blocker.y) < Math.max(row.clearance || HISTORY_NODE_CLEARANCE, blocker.clearance || HISTORY_NODE_CLEARANCE)); if (hit) { ok = false; break; } } if (!ok) return; const anglePenalty = Math.abs(offset) * 18; const score = radius + anglePenalty; if (!best || score < best.score) best = { x: cx, y: cy, score }; }); if (best) return best; radius += HISTORY_CENTER_GAP_STEP; } return { x: Math.cos(baseAngle) * radius, y: Math.sin(baseAngle) * radius }; } function appendStableSnapshot(history, snapshot, transition = {}) { let baseHistory = (Array.isArray(history) ? history : []).filter((row) => normKey(row?.centerLogin) !== normKey(snapshot?.centerLogin)); const existingCenters = historyExistingCenterKeys(baseHistory); const local = layoutFirstLevelEngineModel(snapshot.engineModel, normKey(snapshot.centerLogin), { skipKeys: existingCenters }); const candidate = chooseNewClusterCenter(baseHistory, local, transition); // Новый центр становится (0,0), поэтому старую карту переносим ЦЕЛИКОМ на противоположный вектор. // Внутренние координаты старых кластеров не пересчитываются — они остаются визуально теми же блоками. baseHistory = baseHistory.map((row) => translateSnapshot(row, -candidate.x, -candidate.y)); const shiftedExisting = modelNodePositions(buildStableHistoryEngineModel(baseHistory, HISTORY_MAX_PREVIOUS)); // Если в новом круге встречается уже бывший исторический центр, не затаскиваем его обратно к новому центру: // он остаётся якорем своего старого кластера, а новая связь просто тянется к нему. const finalModel = cloneEngineModel(local); finalModel.nodes = finalModel.nodes.map((node) => { const key = normKey(node?.id); if (key !== normKey(finalModel.focusId) && existingCenters.has(key)) { const old = shiftedExisting.get(key); if (old) return { ...node, layoutX: old.x, layoutY: old.y, fixedLayout: true, tier: 1, keepVisible: true }; } return node; }); return [...baseHistory, { ...snapshot, engineModel: finalModel }]; } 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; 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 = buildStableHistoryEngineModel(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.disabled = x2Enabled; historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false'); historyChip.title = x2Enabled ? 'X2 показывает отдельную карту и временно не использует историю.' : (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.' : 'Показать друзей друзей.'; updateHistoryChip(); } async function toggleX2() { const previousHistory = [...graphHistory]; const previousCenterHistory = [...centerHistory]; const enabling = !x2Enabled; x2Enabled = enabling; // X2 — отдельный режим одной центральной карты. При входе и выходе из него история очищается: // это не ещё один исторический слой, а полный снимок «центр → все друзья → все друзья друзей». centerHistory = []; graphHistory = []; updateX2Chip(); persistHistory(); await load(centerLogin, { pushHistory: false, resetHistory: true }); // Если полный X2 не собрался, load выключает флаг. Возвращаем предыдущую обычную карту, // чтобы сетевой сбой не стирал уже нарисованную историю пользователя. if (enabling && !x2Enabled) { graphHistory = previousHistory; centerHistory = previousCenterHistory; rebuildEngineFromHistory(); persistHistory(); } } 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 persistManualNodePosition(nodeId, point) { const key = normKey(nodeId); const x = Number(point?.x); const y = Number(point?.y); if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. graphHistory = graphHistory.map((snapshot) => { const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); let changed = false; model.nodes = model.nodes.map((node) => { if (normKey(node?.id) !== key) return node; changed = true; return { ...node, layoutX: x, layoutY: y, fixedLayout: true, }; }); return changed ? { ...snapshot, engineModel: model } : snapshot; }); persistHistory(); } function ensureEngine(model) { if (engine) { engine.setModel(model); return; } engine = createForceGraph({ stage: board, model, // тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет onNodeTap: (node) => { const transitionX = Number(node?.x) || 0; const transitionY = Number(node?.y) || 0; const transitionAngle = Math.atan2(transitionY, transitionX); if (x2Enabled) { // Клик внутри X2 начинает новую обычную историю от выбранного человека. x2Enabled = false; centerHistory = []; graphHistory = []; updateX2Chip(); persistHistory(); void load(node.login, { pushHistory: false, resetHistory: true }); return; } void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); }, // тап по центру — полноценный профиль 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)}`) }, ], }); }, // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. onNodeMoveEnd: (node, point) => { persistManualNodePosition(node?.id || node?.login, point); }, }); } async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0, transitionX = 0, transitionY = 0, resetHistory = false, } = {}) { const requestId = ++loadSeq; const prevCenter = centerLogin; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login); try { // Общий module-level cache переживает повторные открытия экрана. Одновременные запросы одного // логина дедуплицируются; для X2 каждый друг первого уровня дополнительно получает один retry. const graph = await getConnectionsGraphCached(targetCenter); if (requestId !== loadSeq) return; centerLogin = targetCenter; syncLinksUrl(targetCenter, { push: pushHistory }); const graphModel = buildGraphModel(graph, targetCenter); let snapshotModel = engineModelFromGraphModel(graphModel); if (x2Enabled) { // СНАЧАЛА полностью собираем второй уровень для КАЖДОГО друга центра, и только после успешного // завершения всех запросов один раз отдаём цельную X2-модель движку. Частичный X2 не рисуем. snapshotModel = await buildSecondLevelEngineModel(snapshotModel, (login) => ( getConnectionsGraphCached(login, { retries: 1 }) )); if (requestId !== loadSeq) return; snapshotModel = layoutX2EngineModel(snapshotModel, normKey(targetCenter)); centerHistory = []; graphHistory = [{ centerLogin: targetCenter, engineModel: snapshotModel, transitionAngle: 0, }]; } else if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { const rawSnapshot = { centerLogin: targetCenter, engineModel: snapshotModel, transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0, }; if (historyDepth > 0) { graphHistory = appendStableSnapshot(graphHistory, rawSnapshot, { transitionAngle, transitionX, transitionY, }); graphHistory = graphHistory.slice(-(historyDepth + 1)); centerHistory.push(prevCenter); centerHistory = centerHistory.slice(-historyDepth); } else { graphHistory = [{ ...rawSnapshot, engineModel: layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)) }]; centerHistory = []; } } else { const last = !resetHistory ? graphHistory[graphHistory.length - 1] : null; const stableModel = last && normKey(last.centerLogin) === normKey(targetCenter) ? refreshFirstLevelSnapshot(last, snapshotModel, normKey(targetCenter)) : layoutFirstLevelEngineModel(snapshotModel, normKey(targetCenter)); centerHistory = []; graphHistory = [{ centerLogin: targetCenter, engineModel: stableModel, transitionAngle: Number(last?.transitionAngle) || 0, }]; } rebuildEngineFromHistory(); persistHistory(); } catch (error) { if (requestId !== loadSeq) return; // Если X2 не удалось собрать полностью, не оставляем интерфейс в ложном активном состоянии. if (x2Enabled) { x2Enabled = false; updateX2Chip(); persistedX2Enabled = false; } 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); }); historyChip = document.createElement('button'); historyChip.type = 'button'; historyChip.className = 'fg-filter-chip fg-history-chip'; historyChip.addEventListener('click', cycleHistoryDepth); filterBar.append(historyChip); updateHistoryChip(); x2Chip = document.createElement('button'); x2Chip.type = 'button'; x2Chip.className = 'fg-filter-chip fg-x2-chip'; x2Chip.textContent = 'X2'; x2Chip.addEventListener('click', () => { void toggleX2(); }); filterBar.append(x2Chip); updateX2Chip(); chrome?.setTopbar(header); stage.append(board, filterBar); screen.append(stage); return screen; }