SHA256
1348 lines
54 KiB
JavaScript
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('<', '<')
|
|
.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 = `
|
|
<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;
|
|
}
|