Files

1348 lines
54 KiB
JavaScript

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 = `
<div class="modal" id="network-search-modal">
<div class="modal-card stack">
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
<h3 class="modal-title">Найти пользователя</h3>
<div class="row" style="gap:8px;">
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
</div>
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div>
<div class="stack" id="network-search-results"></div>
</div>
</div>
`;
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 = '<div class="meta-muted">Кандидаты не найдены.</div>';
applySelection('');
return;
}
resultsEl.innerHTML = items.map((login) => (
`<button type="button" class="ghost-btn network-search-candidate" data-candidate="${escapeHtml(login)}">${escapeHtml(login)}</button>`
)).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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
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;
}