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'; import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.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 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, 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 = ` `; 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'; const routeLogin = normalizeLogin(route?.params?.login || ''); if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; } const screen = document.createElement('section'); screen.className = 'network-screen'; const appScreenEl = document.getElementById('app-screen'); appScreenEl?.classList.add('network-scroll-lock'); const stage = document.createElement('div'); stage.className = 'network-stage'; 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 makeProfileRoute(cleanLogin); } function helpText() { return [ 'Обозначения на экране связей:', '• Синие линии — близкие друзья.', '• Оранжевые линии — родственники.', '• Стрелка на линии — односторонняя связь.', '• Если стрелки нет, связь взаимная.', ].join('\n'); } function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; } 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 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 = `