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

849 lines
28 KiB
JavaScript

import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadUserProfileCard } from '../services/user-connections.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
const GENDER_MALE = 'male';
const GENDER_FEMALE = 'female';
const GENDER_UNKNOWN = 'unknown';
const CENTER_NODE_ID = '__center__';
function normalizeLogin(value) {
return String(value || '').trim();
}
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,
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();
applyRelativeGender(map, graph?.parents);
applyRelativeGender(map, graph?.children);
applyRelativeGender(map, graph?.siblings);
applyRelativeGender(map, graph?.spouses);
return map;
}
function relativeRoleLabel(role, gender) {
const cleanGender = normalizeGender(gender);
if (role === 'parent') {
if (cleanGender === GENDER_MALE) return 'отец';
if (cleanGender === GENDER_FEMALE) return 'мать';
return 'родитель';
}
if (role === 'child') {
if (cleanGender === GENDER_MALE) return 'сын';
if (cleanGender === GENDER_FEMALE) return 'дочь';
return 'потомок';
}
if (role === 'sibling') {
if (cleanGender === GENDER_MALE) return 'брат';
if (cleanGender === GENDER_FEMALE) return 'сестра';
return 'брат/сестра';
}
if (role === 'spouse') {
if (cleanGender === GENDER_MALE) return 'муж';
if (cleanGender === GENDER_FEMALE) return 'жена';
return 'жена/муж';
}
return '';
}
function buildNameLines(firstName, lastName) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
if (first && last) {
const full = `${first} ${last}`.trim();
if (full.length <= 20) return [full];
return [first, last];
}
if (first) return [first];
if (last) return [last];
return [];
}
function applyNodeText(node, {
login,
firstName = '',
lastName = '',
role = 'friend',
gender = GENDER_UNKNOWN,
mark = null,
} = {}) {
const loginText = normalizeLogin(login);
const labelsWrap = node.querySelector('.node-label');
const nameEl = node.querySelector('.node-name');
const loginEl = node.querySelector('.node-login');
const relationEl = node.querySelector('.node-relation');
if (!(labelsWrap instanceof HTMLElement) || !(nameEl instanceof HTMLElement) || !(loginEl instanceof HTMLElement)) {
return;
}
const nameLines = buildNameLines(firstName, lastName);
nameEl.innerHTML = '';
if (nameLines.length) {
nameLines.forEach((line) => {
const lineEl = document.createElement('span');
lineEl.className = 'node-name-line';
lineEl.textContent = line;
nameEl.append(lineEl);
});
labelsWrap.classList.remove('is-login-only');
} else {
labelsWrap.classList.add('is-login-only');
}
loginEl.textContent = loginText;
const relLabel = relativeRoleLabel(role, gender);
if (relationEl instanceof HTMLElement) {
relationEl.textContent = relLabel;
relationEl.hidden = !relLabel;
}
const metaParts = [];
if (mark?.officialLabel) metaParts.push(mark.officialLabel);
if (mark?.shineLabel) metaParts.push(mark.shineLabel);
if (relLabel) metaParts.push(`роль: ${relLabel}`);
const titleMain = nameLines.length ? `${nameLines.join(' ')} (${loginText})` : loginText;
node.title = metaParts.length ? `${titleMain}\n${metaParts.join(', ')}` : titleMain;
}
function spread(count, start, end) {
if (count <= 0) return [];
if (count === 1) return [(start + end) / 2];
const out = [];
const step = (end - start) / (count - 1);
for (let i = 0; i < count; i += 1) out.push(start + step * i);
return out;
}
function buildNodeElement({
login,
kind = 'friend',
isCenter = false,
mark = null,
role = 'friend',
gender = GENDER_UNKNOWN,
firstName = '',
lastName = '',
}) {
const node = document.createElement('button');
node.type = 'button';
const classes = [
'node',
isCenter ? 'center' : '',
kind === 'relative' ? 'is-relative' : 'is-friend',
mark?.shine ? 'is-shine' : '',
mark?.official ? 'is-official' : '',
].filter(Boolean);
node.className = classes.join(' ');
node.dataset.nodeLogin = login;
if (mark?.official) {
const officialBadge = document.createElement('span');
officialBadge.className = 'node-badge-official';
officialBadge.setAttribute('aria-hidden', 'true');
officialBadge.textContent = 'ОФ';
node.append(officialBadge);
}
node.append(renderUserAvatar({
login,
avatar: mark?.avatar || null,
size: 'node',
title: login,
}));
const label = document.createElement('span');
label.className = 'node-label';
label.innerHTML = `
<span class="node-name"></span>
<span class="node-login"></span>
<span class="node-relation" hidden></span>
`;
node.append(label);
applyNodeText(node, { login, firstName, lastName, role, gender, mark });
return node;
}
function buildGraphModel(graph, centerLogin) {
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
const outFriends = toSet(graph?.outFriends);
const inFriends = toSet(graph?.inFriends);
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);
const relativesGender = getRelativeGenderMap(graph);
const allMarks = getMarkByLogin(graph?.allUsers);
const allLogins = uniqueLogins([
...(graph?.outFriends || []),
...(graph?.inFriends || []),
...(graph?.outParents || []),
...(graph?.inParents || []),
...(graph?.outChildren || []),
...(graph?.inChildren || []),
...(graph?.outSiblings || []),
...(graph?.inSiblings || []),
...(graph?.outSpouses || []),
...(graph?.inSpouses || []),
]).filter((entry) => normKey(entry) !== normKey(login));
const relations = allLogins.map((targetLogin) => {
const parentOut = hasLogin(outParents, targetLogin);
const parentIn = hasLogin(inChildren, targetLogin);
const childOut = hasLogin(outChildren, targetLogin);
const childIn = hasLogin(inParents, targetLogin);
const siblingOut = hasLogin(outSiblings, targetLogin);
const siblingIn = hasLogin(inSiblings, targetLogin);
const spouseOut = hasLogin(outSpouses, targetLogin);
const spouseIn = hasLogin(inSpouses, targetLogin);
const friendOut = hasLogin(outFriends, targetLogin);
const friendIn = hasLogin(inFriends, targetLogin);
let role = 'friend';
if (parentOut || parentIn) role = 'parent';
else if (childOut || childIn) role = 'child';
else if (spouseOut || spouseIn) role = 'spouse';
else if (siblingOut || siblingIn) role = 'sibling';
let forward = friendOut;
let backward = friendIn;
if (role === 'parent') {
forward = parentOut;
backward = parentIn;
} else if (role === 'child') {
forward = childOut;
backward = childIn;
} else if (role === 'spouse') {
forward = spouseOut;
backward = spouseIn;
} else if (role === 'sibling') {
forward = siblingOut;
backward = siblingIn;
}
return {
login: targetLogin,
key: normKey(targetLogin),
role,
isRelative: role !== 'friend',
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,
};
}
function splitByGender(list) {
const left = [];
const right = [];
const center = [];
list.forEach((item) => {
if (item.gender === GENDER_FEMALE) left.push(item);
else if (item.gender === GENDER_MALE) right.push(item);
else center.push(item);
});
return { left, right, center };
}
function sortByLogin(items) {
return [...items].sort((a, b) => a.login.localeCompare(b.login, 'ru', { sensitivity: 'base' }));
}
function positionRows(nodes, x, yStart, yEnd) {
const ys = spread(nodes.length, yStart, yEnd);
return nodes.map((node, index) => ({ ...node, x, y: ys[index] }));
}
function layoutNodes(model) {
const centerNode = {
id: CENTER_NODE_ID,
login: model.centerLogin,
x: 50,
y: 50,
isCenter: true,
kind: 'center',
relation: null,
gender: GENDER_UNKNOWN,
mark: model.centerMark,
};
const parents = sortByLogin(model.relations.filter((item) => item.role === 'parent'));
const children = sortByLogin(model.relations.filter((item) => item.role === 'child'));
const spouses = sortByLogin(model.relations.filter((item) => item.role === 'spouse'));
const siblings = sortByLogin(model.relations.filter((item) => item.role === 'sibling'));
const friends = sortByLogin(model.relations.filter((item) => item.role === 'friend'));
const parentSplit = splitByGender(parents);
const childSplit = splitByGender(children);
const spouseSplit = splitByGender(spouses);
const siblingSplit = splitByGender(siblings);
const friendLeft = [];
const friendRight = [];
friends.forEach((item, index) => {
if (index % 2 === 0) friendLeft.push(item);
else friendRight.push(item);
});
const positioned = [
...positionRows(parentSplit.left, 28, 16, 28),
...positionRows(parentSplit.right, 72, 16, 28),
...positionRows(parentSplit.center, 50, 10, 22),
...positionRows(friendLeft, 12, 30, 70),
...positionRows(friendRight, 88, 30, 70),
...positionRows(spouseSplit.left, 36, 38, 58),
...positionRows(spouseSplit.right, 64, 38, 58),
...positionRows(spouseSplit.center, 50, 40, 56),
...positionRows(siblingSplit.left, 30, 46, 66),
...positionRows(siblingSplit.right, 70, 46, 66),
...positionRows(siblingSplit.center, 50, 54, 70),
...positionRows(childSplit.left, 28, 68, 84),
...positionRows(childSplit.right, 72, 68, 84),
...positionRows(childSplit.center, 50, 78, 88),
];
const nodes = [centerNode];
const edges = [];
positioned.forEach((item) => {
const nodeId = item.key;
nodes.push({
id: nodeId,
login: item.login,
x: item.x,
y: item.y,
isCenter: false,
kind: item.isRelative ? 'relative' : 'friend',
relation: item.role,
gender: item.gender,
mark: item.mark,
});
edges.push({
from: item.forward ? CENTER_NODE_ID : nodeId,
to: item.forward ? nodeId : CENTER_NODE_ID,
mutual: item.forward && item.backward,
isRelative: item.isRelative,
exists: item.forward || item.backward,
});
});
return { nodes, edges };
}
function marker(svg, id, color) {
const el = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
el.setAttribute('id', id);
el.setAttribute('viewBox', '0 0 10 10');
el.setAttribute('refX', '9');
el.setAttribute('refY', '5');
el.setAttribute('markerUnits', 'strokeWidth');
el.setAttribute('markerWidth', '6');
el.setAttribute('markerHeight', '6');
el.setAttribute('orient', 'auto');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
path.setAttribute('fill', color);
el.append(path);
svg.append(el);
}
function getNodeCenter(boardRect, node) {
const dot = node.querySelector('.node-dot');
if (!(dot instanceof HTMLElement)) return null;
const rect = dot.getBoundingClientRect();
return {
x: rect.left - boardRect.left + (rect.width / 2),
y: rect.top - boardRect.top + (rect.height / 2),
radius: rect.width / 2,
};
}
function shortenLine(fromPoint, toPoint, fromOffset, toOffset) {
const dx = toPoint.x - fromPoint.x;
const dy = toPoint.y - fromPoint.y;
const len = Math.hypot(dx, dy);
if (len < 1) return null;
const ux = dx / len;
const uy = dy / len;
return {
x1: fromPoint.x + (ux * fromOffset),
y1: fromPoint.y + (uy * fromOffset),
x2: toPoint.x - (ux * toOffset),
y2: toPoint.y - (uy * toOffset),
};
}
function renderEdges(svg, board, nodeElements, edges) {
svg.innerHTML = '';
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
marker(defs, 'network-arrow-friend', 'rgba(120, 179, 255, 0.95)');
marker(defs, 'network-arrow-relative', 'rgba(255, 159, 94, 0.95)');
svg.append(defs);
const boardRect = board.getBoundingClientRect();
const centers = new Map();
nodeElements.forEach((value, key) => {
const pt = getNodeCenter(boardRect, value);
if (pt) centers.set(key, pt);
});
edges.forEach((edge) => {
if (!edge.exists) return;
const from = centers.get(edge.from);
const to = centers.get(edge.to);
if (!from || !to) return;
const cut = shortenLine(from, to, from.radius + 3, to.radius + 3);
if (!cut) return;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', String(cut.x1));
line.setAttribute('y1', String(cut.y1));
line.setAttribute('x2', String(cut.x2));
line.setAttribute('y2', String(cut.y2));
line.setAttribute('class', `network-link ${edge.isRelative ? 'is-relative' : 'is-friend'}`);
if (!edge.mutual) {
line.setAttribute('marker-end', `url(#${edge.isRelative ? 'network-arrow-relative' : 'network-arrow-friend'})`);
}
svg.append(line);
});
}
let persistedCenterLogin = '';
let persistedCenterHistory = [];
export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
if (!keepHistory) {
persistedCenterLogin = '';
persistedCenterHistory = [];
}
const screen = document.createElement('section');
screen.className = 'stack network-screen';
const board = document.createElement('div');
board.className = 'network-board network-board--full';
const profileCardCache = new Map();
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
let redrawEdges = () => {};
let loadSeq = 0;
function profileInfoRoute(login) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) return '';
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
return `user-profile-view/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
}
function helpText() {
return [
'Обозначения на экране связей:',
'• Синие линии — близкие друзья.',
'• Оранжевые линии — родственники.',
'• Стрелка на линии — односторонняя связь.',
'• Если стрелки нет, связь взаимная.',
].join('\n');
}
function persistHistory() {
persistedCenterLogin = centerLogin;
persistedCenterHistory = [...centerHistory];
}
function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0;
}
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">Введите логин и нажмите «Искать».</div>
<div class="stack" id="network-search-results"></div>
<div class="form-actions-grid">
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
</div>
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
</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');
const profileBtn = root.querySelector('#network-search-profile');
const graphBtn = root.querySelector('#network-search-graph');
const okBtn = root.querySelector('#network-search-ok');
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 hasSelected = Boolean(selectedLogin);
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
};
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(); });
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;
applySelection(String(button.dataset.candidate || ''));
});
profileBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
const routeTo = profileInfoRoute(selectedLogin);
if (!routeTo) return;
close();
navigate(routeTo);
});
graphBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
close();
void load(selectedLogin, { pushHistory: true });
});
okBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
});
window.setTimeout(() => inputEl.focus(), 0);
}
function getProfileCardCached(login) {
const cleanLogin = normalizeLogin(login);
const key = normKey(cleanLogin);
if (!cleanLogin) return Promise.resolve(null);
if (!profileCardCache.has(key)) {
profileCardCache.set(key, loadUserProfileCard(cleanLogin).catch(() => null));
}
return profileCardCache.get(key);
}
async function hydrateNodeProfiles(layout, nodeElements, requestId) {
const uniqueNodes = [];
const seen = new Set();
layout.nodes.forEach((nodeModel) => {
const key = normKey(nodeModel.login);
if (!key || seen.has(key)) return;
seen.add(key);
uniqueNodes.push(nodeModel);
});
const cards = await Promise.all(uniqueNodes.map((nodeModel) => getProfileCardCached(nodeModel.login)));
if (requestId !== loadSeq) return;
const cardByKey = new Map();
cards.forEach((card) => {
const login = normalizeLogin(card?.login);
if (!login) return;
cardByKey.set(normKey(login), card);
});
layout.nodes.forEach((nodeModel) => {
const node = nodeElements.get(nodeModel.id);
if (!(node instanceof HTMLElement)) return;
const card = cardByKey.get(normKey(nodeModel.login));
const cardGender = normalizeGender(card?.gender);
applyNodeText(node, {
login: nodeModel.login,
firstName: card?.firstName || '',
lastName: card?.lastName || '',
role: nodeModel.relation || 'friend',
gender: nodeModel.gender === GENDER_UNKNOWN ? cardGender : nodeModel.gender,
mark: nodeModel.mark || null,
});
});
requestAnimationFrame(() => redrawEdges());
}
function bindNodeInteraction(node, nodeModel) {
node.addEventListener('click', () => {
if (nodeModel.isCenter) {
const routeTo = profileInfoRoute(nodeModel.login);
if (routeTo) navigate(routeTo);
return;
}
void load(nodeModel.login, { pushHistory: true });
});
}
async function load(nextCenterLogin = '', { pushHistory = false } = {}) {
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);
}
const model = buildGraphModel(graph, targetCenter);
const layout = layoutNodes(model);
board.innerHTML = '';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'network-svg');
board.append(svg);
const nodeElements = new Map();
layout.nodes.forEach((nodeModel) => {
const node = buildNodeElement({
login: nodeModel.login,
kind: nodeModel.kind,
isCenter: nodeModel.isCenter,
role: nodeModel.relation || 'friend',
gender: nodeModel.gender,
mark: nodeModel.mark,
});
node.style.left = `${nodeModel.x}%`;
node.style.top = `${nodeModel.y}%`;
board.append(node);
nodeElements.set(nodeModel.id, node);
bindNodeInteraction(node, nodeModel);
});
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
requestAnimationFrame(() => redrawEdges());
void hydrateNodeProfiles(layout, nodeElements, requestId);
persistHistory();
setBackButtonState(backBtnEl);
} catch (error) {
if (requestId !== loadSeq) return;
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
}
}
const header = renderHeader({
title: 'Связи',
leftAction: {
label: '←',
onClick: () => {
if (!centerHistory.length) return;
const prev = centerHistory.pop();
if (!prev) {
setBackButtonState(backBtnEl);
return;
}
void load(prev, { pushHistory: false });
},
},
rightActions: [
{ label: 'Найти человека', onClick: openSearchModal },
{ label: '?', onClick: () => window.alert(helpText()) },
],
});
const backBtnEl = header.querySelector('.header-left .icon-btn');
setBackButtonState(backBtnEl);
const onResize = () => redrawEdges();
window.addEventListener('resize', onResize);
let observer = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => redrawEdges());
observer.observe(board);
}
screen.cleanup = () => {
window.removeEventListener('resize', onResize);
if (observer) observer.disconnect();
};
if (keepHistory && centerLogin) {
void load(centerLogin, { pushHistory: false });
} else {
centerLogin = normalizeLogin(state.session.login || '');
centerHistory = [];
persistHistory();
void load(centerLogin, { pushHistory: false });
}
setBackButtonState(backBtnEl);
screen.append(header, board);
return screen;
}