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

818 lines
31 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),
official: Boolean(row?.official),
shine: Boolean(row?.shine),
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 = [];
const HISTORY_MAX_CENTERS = 4;
const HISTORY_CENTER_GAP_MIN = 280;
const HISTORY_CENTER_GAP_STEP = 72;
const HISTORY_CENTER_CLEARANCE = 110;
const HISTORY_ORBIT_FIRST_R = 104;
const HISTORY_ORBIT_GAP = 88;
const HISTORY_ORBIT_NODE_GAP = 84;
const HISTORY_NODE_CLEARANCE = 92;
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) % 1000) / 1000;
}
function historyOrbitCapacity(radius) {
const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1);
const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r)));
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
}
function historyOrbitPlacements(total, seed = '') {
const count = Math.max(0, Number(total) || 0);
const out = new Array(count);
let start = 0;
let ring = 0;
const seedPhase = historyHash01(seed) * Math.PI * 2;
while (start < count) {
const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP;
const capacity = historyOrbitCapacity(radius);
const ringCount = Math.min(capacity, count - start);
const phase = seedPhase + ring * 0.43;
for (let i = 0; i < ringCount; i += 1) {
out[start + i] = {
radius,
angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount),
};
}
start += ringCount;
ring += 1;
}
return out;
}
function historyClusterRadius(snapshot) {
const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : [];
const centerKey = normKey(snapshot?.centerLogin);
const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length;
const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || '');
const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0);
return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE;
}
function buildHistoryEngineModel(history) {
const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS);
const latest = snapshots[snapshots.length - 1];
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
const centerPos = new Map();
const centerOrder = new Map();
const clusterRadius = new Map();
centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 });
snapshots.forEach((snap, index) => {
const key = normKey(snap.centerLogin);
centerOrder.set(key, index);
clusterRadius.set(key, historyClusterRadius(snap));
});
// Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода
// настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались.
for (let i = snapshots.length - 1; i > 0; i -= 1) {
const current = snapshots[i];
const previous = snapshots[i - 1];
const currentKey = normKey(current.centerLogin);
const previousKey = normKey(previous.centerLogin);
const curPos = centerPos.get(currentKey) || { x: 0, y: 0 };
const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0;
const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE;
const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE;
let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR);
let candidate = null;
for (let attempt = 0; attempt < 24; attempt += 1) {
candidate = {
x: curPos.x - Math.cos(angle) * gap,
y: curPos.y - Math.sin(angle) * gap,
};
let collides = false;
for (const [placedKey, placed] of centerPos.entries()) {
const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE;
const minDist = prevR + placedR;
if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) {
collides = true;
break;
}
}
if (!collides) break;
gap += HISTORY_CENTER_GAP_STEP;
}
centerPos.set(previousKey, candidate || {
x: curPos.x - Math.cos(angle) * gap,
y: curPos.y - Math.sin(angle) * gap,
});
}
const nodeMap = new Map();
const latestPlacementParent = new Map();
const edgeMap = new Map();
// Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям.
snapshots.forEach((snap, snapIndex) => {
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
const centerKey = normKey(snap.centerLogin);
const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0];
if (center) {
nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true });
}
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
relations.forEach((node) => {
const key = normKey(node?.id);
if (!key) return;
nodeMap.set(key, { ...nodeMap.get(key), ...node });
const a = centerKey;
const b = key;
const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`;
edgeMap.set(edgeKey, {
a,
b,
relationType: node.relationType || 'contact',
strength: Number(node.strength) || 0.5,
snapIndex,
});
});
});
// Все исторические центры сразу считаются занятыми местами.
const occupied = [];
for (const [centerKey, pos] of centerPos.entries()) {
occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE });
}
const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => {
if (row.key === key) return false;
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
});
// Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь
// встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге.
for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) {
const snap = snapshots[snapIndex];
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
const centerKey = normKey(snap.centerLogin);
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
const placements = historyOrbitPlacements(relations.length, snap.centerLogin);
const base = centerPos.get(centerKey) || { x: 0, y: 0 };
relations.forEach((node, index) => {
const key = normKey(node?.id);
if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return;
const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 };
let chosen = null;
// Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус.
for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) {
const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55;
const angularSamples = 18 + radialStep * 2;
for (let step = 0; step < angularSamples; step += 1) {
const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1);
const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples);
const x = base.x + Math.cos(angle) * radius;
const y = base.y + Math.sin(angle) * radius;
if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) {
chosen = { x, y };
break;
}
}
}
if (!chosen) {
const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55;
chosen = {
x: base.x + Math.cos(preferred.angle) * radius,
y: base.y + Math.sin(preferred.angle) * radius,
};
}
latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex });
occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE });
});
}
for (const [centerKey, pos] of centerPos.entries()) {
const node = nodeMap.get(centerKey);
if (!node) continue;
nodeMap.set(centerKey, {
...node,
layoutX: pos.x,
layoutY: pos.y,
fixedLayout: true,
keepVisible: true,
isHistoryCenter: true,
});
}
for (const [key, placement] of latestPlacementParent.entries()) {
const node = nodeMap.get(key);
if (!node || centerOrder.has(key)) continue;
nodeMap.set(key, {
...node,
layoutX: placement.x,
layoutY: placement.y,
fixedLayout: true,
parentId: placement.centerKey,
});
}
const edgeParentsByChild = new Map();
const addEdgeParent = (childKey, parentKey, edge) => {
if (!childKey || !parentKey || childKey === parentKey) return;
const list = edgeParentsByChild.get(childKey) || [];
if (!list.some((row) => row.id === parentKey)) {
list.push({
id: parentKey,
relationType: edge.relationType,
strength: edge.strength,
shining: edge.shining,
});
}
edgeParentsByChild.set(childKey, list);
};
for (const edge of edgeMap.values()) {
const aCenterIndex = centerOrder.get(edge.a);
const bCenterIndex = centerOrder.get(edge.b);
if (aCenterIndex !== undefined && bCenterIndex !== undefined) {
if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge);
else addEdgeParent(edge.b, edge.a, edge);
continue;
}
if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge);
else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge);
}
const nodes = [...nodeMap.entries()].map(([key, node]) => ({
...node,
id: key,
login: node.login || node.id || key,
tier: 1,
fixedLayout: true,
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 engine = null;
let loadSeq = 0;
// Независимые фильтры карты. Оба выключены = показываем всё.
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
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];
}
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);
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);
const snapshotModel = engineModelFromGraphModel(graphModel);
const snapshot = {
centerLogin: targetCenter,
engineModel: snapshotModel,
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
};
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
// Один и тот же пользователь не хранится как два исторических центра: повторный переход
// переносит его в конец истории, где он становится актуальным центром.
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
graphHistory.push(snapshot);
if (graphHistory.length > HISTORY_MAX_CENTERS) {
graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS);
centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1));
}
} 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];
}
}
const engineModel = buildHistoryEngineModel(graphHistory);
ensureEngine(engineModel);
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
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);
});
chrome?.setTopbar(header);
stage.append(board, filterBar);
screen.append(stage);
return screen;
}