Files
SHiNE-server/shine-UI/js/pages/network-view.js
T

1083 lines
42 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_START = 108;
const HISTORY_CENTER_GAP_STEP = 28;
const HISTORY_CENTER_CLEARANCE = 88;
const HISTORY_NODE_CLEARANCE = 82;
const HISTORY_TIER2_CLEARANCE = 48;
const HISTORY_DIRECT_SPACING = 92;
const HISTORY_TIER2_SPACING = 54;
const HISTORY_LAYOUT_MAX_SHELL = 9;
function historyHash01(value) {
let h = 2166136261;
const text = String(value || '');
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 100000) / 100000;
}
function historySquareShellCells(shell) {
const r = Math.max(1, Math.trunc(shell));
const cells = [];
for (let y = -r; y <= r; y += 1) {
for (let x = -r; x <= r; x += 1) {
if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue;
cells.push({ x, y });
}
}
return cells;
}
// Компактная «квадратная» раскладка: сначала заполняем ближайший квадратный пояс,
// причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей
// не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра.
function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) {
const count = Math.max(0, Math.trunc(Number(total) || 0));
const out = [];
let globalIndex = 0;
for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) {
const remaining = historySquareShellCells(shell);
const ordered = [];
const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0;
if (remaining.length) ordered.push(remaining.splice(start, 1)[0]);
while (remaining.length) {
let bestIndex = 0;
let bestScore = -Infinity;
remaining.forEach((cell, index) => {
let minD2 = Infinity;
for (const used of ordered) {
const dx = cell.x - used.x;
const dy = cell.y - used.y;
minD2 = Math.min(minD2, dx * dx + dy * dy);
}
const jitter = historyHash01(`${seed}|${shell}|${cell.x}|${cell.y}`) * 0.05;
const score = minD2 + jitter;
if (score > bestScore) { bestScore = score; bestIndex = index; }
});
ordered.push(remaining.splice(bestIndex, 1)[0]);
}
const quarterTurns = Math.floor(historyHash01(`${seed}|rotate`) * 4);
const turn = (cell) => {
let { x, y } = cell;
for (let i = 0; i < quarterTurns; i += 1) [x, y] = [-y, x];
return { x, y };
};
for (const raw of ordered) {
if (out.length >= count) break;
const cell = turn(raw);
const jx = (historyHash01(`${seed}|${globalIndex}|x`) - 0.5) * spacing * 0.10;
const jy = (historyHash01(`${seed}|${globalIndex}|y`) - 0.5) * spacing * 0.10;
out.push({
x: cell.x * spacing + jx,
y: cell.y * spacing + jy,
shell,
});
globalIndex += 1;
}
}
return out;
}
function historyIdealShell(count) {
const n = Math.max(0, Math.trunc(Number(count) || 0));
if (!n) return 0;
let shell = 1;
while (4 * shell * (shell + 1) < n) shell += 1;
return shell;
}
function relationEdge(parentId, node) {
return {
id: normKey(parentId),
relationType: String(node?.relationType || 'contact'),
strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)),
};
}
function stripSecondLevel(model) {
const nodes = Array.isArray(model?.nodes) ? model.nodes : [];
const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id)));
return {
...model,
nodes: nodes
.filter((node) => keep.has(normKey(node?.id)))
.map((node) => ({
...node,
edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : [])
.filter((edge) => keep.has(normKey(edge?.id))),
})),
};
}
async function mapWithConcurrency(items, concurrency, worker) {
const list = Array.isArray(items) ? items : [];
const out = new Array(list.length);
let cursor = 0;
const runners = new Array(Math.min(Math.max(1, concurrency), list.length)).fill(0).map(async () => {
while (cursor < list.length) {
const index = cursor++;
try { out[index] = await worker(list[index], index); }
catch (error) { out[index] = { error }; }
}
});
await Promise.all(runners);
return out;
}
async function buildSecondLevelEngineModel(baseModel, getGraph) {
const focusKey = normKey(baseModel?.focusId);
const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : [];
const byKey = new Map();
baseNodes.forEach((src) => {
const key = normKey(src?.id);
if (!key) return;
const isFocus = key === focusKey;
const normalized = {
...src,
id: key,
login: src?.login || src?.id || key,
tier: 1,
parentId: isFocus ? '' : focusKey,
edgeParents: isFocus ? [] : [relationEdge(focusKey, src)],
};
byKey.set(key, normalized);
});
const directFriends = [...byKey.values()].filter((node) => (
normKey(node.id) !== focusKey
&& (node.relationType === 'friend' || node.relationType === 'close_friend')
));
const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => {
const graph = await getGraph(parent.login || parent.id);
const graphModel = buildGraphModel(graph, parent.login || parent.id);
return { parent, model: engineModelFromGraphModel(graphModel) };
});
fetched.forEach((row) => {
if (!row || row.error || !row.model) return;
const parentKey = normKey(row.parent?.id);
const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : [];
childNodes.forEach((child) => {
const childKey = normKey(child?.id);
if (!childKey || childKey === parentKey || childKey === focusKey) return;
if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return;
const edge = relationEdge(parentKey, child);
const existing = byKey.get(childKey);
if (existing) {
const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : [];
if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge);
byKey.set(childKey, { ...existing, edgeParents: refs });
return;
}
byKey.set(childKey, {
...child,
id: childKey,
login: child?.login || child?.id || childKey,
tier: 2,
parentId: parentKey,
alwaysVisible: true,
edgeParents: [edge],
});
});
});
return { ...baseModel, nodes: [...byKey.values()] };
}
function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) {
const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0)));
const maxSnapshots = keepPrevious + 1;
const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots);
const latest = snapshots[snapshots.length - 1];
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
const centerOrder = new Map();
const nodeMap = new Map();
const latestOwner = new Map();
const edgeMap = new Map();
snapshots.forEach((snap, snapIndex) => {
const centerKey = normKey(snap?.centerLogin);
if (!centerKey) return;
centerOrder.set(centerKey, snapIndex);
const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
modelNodes.forEach((rawNode) => {
const key = normKey(rawNode?.id);
if (!key) return;
const node = {
...nodeMap.get(key),
...rawNode,
id: key,
login: rawNode?.login || rawNode?.id || key,
};
if (key === centerKey) {
node.tier = 1;
node.keepVisible = true;
node.isHistoryCenter = true;
}
nodeMap.set(key, node);
latestOwner.set(key, snapIndex);
if (key === centerKey) return;
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)];
parents.forEach((ref) => {
const parentKey = normKey(ref?.id || centerKey);
if (!parentKey || parentKey === key) return;
const a = parentKey < key ? parentKey : key;
const b = parentKey < key ? key : parentKey;
edgeMap.set(`${a}|${b}`, {
parent: parentKey,
child: key,
relationType: String(ref?.relationType || rawNode?.relationType || 'contact'),
strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)),
snapIndex,
});
});
});
});
const occupied = [];
const placed = new Map();
const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE;
const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => {
if (ignoreKey && row.key === ignoreKey) return false;
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
});
function ownedNodesForSnapshot(snapIndex) {
const centerKey = normKey(snapshots[snapIndex]?.centerLogin);
return [...nodeMap.entries()]
.filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key))
.map(([key, node]) => ({ key, node }));
}
function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) {
const snap = snapshots[snapIndex];
const centerKey = normKey(snap?.centerLogin);
const localPlaced = new Map([[centerKey, { ...center }]]);
const rows = baseOccupied.map((row) => ({ ...row }));
if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) {
return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows };
}
rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE });
const owned = ownedNodesForSnapshot(snapIndex);
const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2);
const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2);
const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING);
let maxDirectShell = 0;
direct.forEach(({ key, node }) => {
let chosen = null;
const clearance = clearanceForNode(node);
for (const slot of directSlots) {
const x = center.x + slot.x;
const y = center.y + slot.y;
if (!collides(x, y, clearance, rows, key)) {
chosen = { x, y, shell: slot.shell };
break;
}
}
if (!chosen) {
const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2;
const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1);
chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 };
}
maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0);
localPlaced.set(key, chosen);
rows.push({ key, x: chosen.x, y: chosen.y, clearance });
});
const deepByParent = new Map();
deep.forEach((row) => {
const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey);
const list = deepByParent.get(parentKey) || [];
list.push(row);
deepByParent.set(parentKey, list);
});
for (const [parentKey, children] of deepByParent.entries()) {
const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center;
const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6);
children.forEach(({ key, node }) => {
let chosen = null;
const clearance = clearanceForNode(node);
for (const slot of slots) {
const x = parentPos.x + slot.x;
const y = parentPos.y + slot.y;
if (!collides(x, y, clearance, rows, key)) {
chosen = { x, y, shell: slot.shell };
break;
}
}
if (!chosen) {
const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2;
const radius = HISTORY_TIER2_SPACING * 7;
chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 };
}
localPlaced.set(key, chosen);
rows.push({ key, x: chosen.x, y: chosen.y, clearance });
});
}
const idealDirectShell = historyIdealShell(direct.length);
const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1);
if (commit) {
localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y }));
occupied.splice(0, occupied.length, ...rows);
}
return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows };
}
// Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно.
const latestIndex = snapshots.length - 1;
tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true);
// Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум:
// сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем
// наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap.
// Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку.
const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2];
const compactnessScore = (rows, desiredAngleOffset = 0) => {
if (!rows.length) return 0;
let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity;
rows.forEach((row) => {
const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE;
minX = Math.min(minX, row.x - c * 0.5);
maxX = Math.max(maxX, row.x + c * 0.5);
minY = Math.min(minY, row.y - c * 0.5);
maxY = Math.max(maxY, row.y + c * 0.5);
});
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
const area = width * height;
const aspectPenalty = Math.abs(Math.log(width / height));
// Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход
// от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот.
return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600;
};
for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) {
const newer = snapshots[snapIndex + 1];
const newerKey = normKey(newer?.centerLogin);
const newerPos = placed.get(newerKey) || { x: 0, y: 0 };
const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0;
let gap = HISTORY_CENTER_GAP_START;
let best = null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const candidates = [];
angleOffsets.forEach((offset) => {
const angle = baseAngle + offset;
const candidate = {
x: newerPos.x - Math.cos(angle) * gap,
y: newerPos.y - Math.sin(angle) * gap,
};
const trial = tryPlaceCluster(snapIndex, candidate, occupied, false);
if (!trial.ok) return;
candidates.push({
candidate,
offset,
score: compactnessScore(trial.rows, offset),
});
});
if (candidates.length) {
candidates.sort((a, b) => a.score - b.score);
best = candidates[0];
break;
}
gap += HISTORY_CENTER_GAP_STEP;
}
const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8);
const candidate = best?.candidate || {
x: newerPos.x - Math.cos(fallbackAngle) * gap,
y: newerPos.y - Math.sin(fallbackAngle) * gap,
};
tryPlaceCluster(snapIndex, candidate, occupied, true);
}
// На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot.
snapshots.forEach((snap) => {
const key = normKey(snap?.centerLogin);
if (!placed.has(key)) placed.set(key, { x: 0, y: 0 });
});
const edgeParentsByChild = new Map();
const addEdgeParent = (childKey, parentKey, edge) => {
if (!childKey || !parentKey || childKey === parentKey) return;
const list = edgeParentsByChild.get(childKey) || [];
if (!list.some((row) => normKey(row?.id) === parentKey)) {
list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength });
}
edgeParentsByChild.set(childKey, list);
};
for (const edge of edgeMap.values()) {
if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue;
addEdgeParent(edge.child, edge.parent, edge);
}
const nodes = [...nodeMap.entries()].map(([key, node]) => {
const pos = placed.get(key) || { x: 0, y: 0 };
const isCenter = centerOrder.has(key);
return {
...node,
id: key,
login: node.login || node.id || key,
tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1),
layoutX: pos.x,
layoutY: pos.y,
fixedLayout: true,
keepVisible: isCenter || Boolean(node.keepVisible),
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
edgeParents: edgeParentsByChild.get(key) || [],
};
});
return {
focusId: normKey(latest.engineModel.focusId),
nodes,
preserveHistory: snapshots.length > 1,
};
}
export function render({ navigate, route, chrome } = {}) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) {
persistedCenterLogin = '';
persistedCenterHistory = [];
persistedGraphHistory = [];
}
const screen = document.createElement('section');
screen.className = 'network-screen';
const stage = document.createElement('div');
stage.className = 'network-stage';
const board = document.createElement('div');
board.className = 'network-board network-board--full fg-stage';
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
let historyDepth = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Number(persistedHistoryDepth) || 0));
let x2Enabled = Boolean(persistedX2Enabled);
let engine = null;
let loadSeq = 0;
const graphCache = new Map();
let historyChip = null;
let x2Chip = null;
// Независимые фильтры карты. Оба выключены = показываем всё.
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
const FILTERS = {
close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['close', 'shining'];
const activeFilters = new Set();
const filterChips = {};
function currentFilterPredicate(node) {
for (const key of activeFilters) {
if (!FILTERS[key].pred(node)) return false;
}
return true;
}
function applyFilter(key) {
if (!FILTERS[key]) return;
if (activeFilters.has(key)) activeFilters.delete(key);
else activeFilters.add(key);
FILTER_ORDER.forEach((k) => {
const el = filterChips[k];
if (el) el.classList.toggle('is-active', activeFilters.has(k));
});
if (engine) engine.setFilter(currentFilterPredicate);
}
function profileInfoRoute(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) return '';
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
return makeProfileRoute(cleanLogin);
}
function persistHistory() {
persistedCenterLogin = centerLogin;
persistedCenterHistory = [...centerHistory];
persistedGraphHistory = [...graphHistory];
persistedHistoryDepth = historyDepth;
persistedX2Enabled = x2Enabled;
}
function rebuildEngineFromHistory() {
const engineModel = buildHistoryEngineModel(graphHistory, historyDepth);
ensureEngine(engineModel);
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
}
function updateHistoryChip() {
if (!(historyChip instanceof HTMLButtonElement)) return;
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
historyChip.classList.toggle('is-active', historyDepth > 0);
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
historyChip.title = historyDepth > 0
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.';
}
function cycleHistoryDepth() {
historyDepth = historyDepth >= HISTORY_MAX_PREVIOUS ? 0 : historyDepth + 1;
const maxSnapshots = historyDepth + 1;
graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1);
centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : [];
updateHistoryChip();
rebuildEngineFromHistory();
persistHistory();
}
function updateX2Chip() {
if (!(x2Chip instanceof HTMLButtonElement)) return;
x2Chip.classList.toggle('is-active', x2Enabled);
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
}
async function toggleX2() {
x2Enabled = !x2Enabled;
updateX2Chip();
persistedX2Enabled = x2Enabled;
await load(centerLogin, { pushHistory: false });
}
function syncLinksUrl(login, { push = false } = {}) {
const clean = normalizeLogin(login);
if (!clean) return;
const nextPath = `/${makeProfileLinksRoute(clean)}`;
if (window.location.pathname === nextPath) return;
if (push) window.history.pushState({}, '', nextPath);
else window.history.replaceState({}, '', nextPath);
}
function openSearchModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return;
root.innerHTML = `
<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 ensureEngine(model) {
if (engine) {
engine.setModel(model);
return;
}
engine = createForceGraph({
stage: board,
model,
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
onNodeTap: (node) => {
const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0);
void load(node.login, { pushHistory: true, transitionAngle });
},
// тап по центру — полноценный профиль
onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login);
if (routeTo) navigate(routeTo);
},
// долгое нажатие — контекстное меню (вне масштабируемого холста)
onNodeLongPress: (node, point) => {
const login = normalizeLogin(node.login);
openNodeMenu({
login,
displayName: String(node.name || '').trim(),
relationType: node.relationType,
point,
actions: [
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } },
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(login)}`) },
],
});
},
});
}
async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) {
const requestId = ++loadSeq;
const prevCenter = centerLogin;
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
try {
const graph = await authService.getUserConnectionsGraph(targetCenter);
graphCache.set(normKey(targetCenter), graph);
if (requestId !== loadSeq) return;
centerLogin = targetCenter;
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
centerHistory.push(prevCenter);
}
syncLinksUrl(targetCenter, { push: pushHistory });
const graphModel = buildGraphModel(graph, targetCenter);
let snapshotModel = engineModelFromGraphModel(graphModel);
if (x2Enabled) {
snapshotModel = await buildSecondLevelEngineModel(snapshotModel, async (login) => {
const key = normKey(login);
if (graphCache.has(key)) return graphCache.get(key);
const childGraph = await authService.getUserConnectionsGraph(login);
graphCache.set(key, childGraph);
return childGraph;
});
if (requestId !== loadSeq) return;
}
const snapshot = {
centerLogin: targetCenter,
engineModel: snapshotModel,
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
};
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
// X2 относится только к ТЕКУЩЕМУ центру. Как только центр уходит в историю, оставляем его
// первый уровень — иначе четыре исторических слоя с друзьями друзей быстро превратятся в кашу.
graphHistory = graphHistory.map((row) => ({ ...row, engineModel: stripSecondLevel(row.engineModel) }));
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
graphHistory.push(snapshot);
const maxSnapshots = historyDepth + 1;
graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1);
centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : [];
} else {
const last = graphHistory[graphHistory.length - 1];
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
snapshot.transitionAngle = Number(last.transitionAngle) || 0;
graphHistory[graphHistory.length - 1] = snapshot;
} else {
graphHistory = [snapshot];
}
}
rebuildEngineFromHistory();
persistHistory();
} catch (error) {
if (requestId !== loadSeq) return;
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;
}