import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.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 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 ? 'сияющий' : 'несияющий')), }); }); return map; } 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); return map; } 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 }) { 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; const metaParts = []; if (mark?.officialLabel) metaParts.push(mark.officialLabel); if (mark?.shineLabel) metaParts.push(mark.shineLabel); const metaText = metaParts.join(', '); node.title = metaText ? `${login}\n${metaText}` : login; const officialBadge = mark?.official ? '' : ''; node.innerHTML = ` ${officialBadge} ${(login[0] || '?').toUpperCase()} ${login} `; 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 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 || []), ]).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 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 (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 === '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, mark: model.centerMark, }; const parents = sortByLogin(model.relations.filter((item) => item.role === 'parent')); const children = sortByLogin(model.relations.filter((item) => item.role === 'child')); 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 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, 14, 30), ...positionRows(parentSplit.right, 72, 14, 30), ...positionRows(parentSplit.center, 50, 10, 22), ...positionRows(friendLeft, 12, 28, 72), ...positionRows(friendRight, 88, 28, 72), ...positionRows(siblingSplit.left, 30, 48, 70), ...positionRows(siblingSplit.right, 70, 48, 70), ...positionRows(siblingSplit.center, 50, 58, 74), ...positionRows(childSplit.left, 28, 70, 90), ...positionRows(childSplit.right, 72, 70, 90), ...positionRows(childSplit.center, 50, 82, 94), ]; 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, 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); }); } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; const board = document.createElement('div'); board.className = 'network-board'; board.style.height = 'calc(100dvh - 170px)'; const note = document.createElement('p'); note.className = 'meta-muted'; note.textContent = 'Загрузка связей...'; const legend = document.createElement('div'); legend.className = 'network-legend'; legend.innerHTML = ` Близкие друзья Родственники Односторонняя связь `; let activeMenu = null; let centerLogin = state.session.login || ''; let redrawEdges = () => {}; let loadSeq = 0; function closeNodeMenu() { if (!activeMenu) return; activeMenu.remove(); activeMenu = null; } function openNodeMenu(node, login) { closeNodeMenu(); const menu = document.createElement('div'); menu.className = 'node-menu card'; menu.innerHTML = `
`; const rect = node.getBoundingClientRect(); const boardRect = board.getBoundingClientRect(); const x = rect.left + rect.width / 2 - boardRect.left; const y = rect.bottom - boardRect.top + 8; menu.style.left = `${Math.max(8, Math.min(x - 120, boardRect.width - 248))}px`; menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`; const infoBtn = menu.querySelector('[data-menu-action="show-info"]'); const graphBtn = menu.querySelector('[data-menu-action="show-graph"]'); infoBtn?.addEventListener('click', () => { navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`); closeNodeMenu(); }); graphBtn?.addEventListener('click', async () => { closeNodeMenu(); await load(login); }); board.append(menu); activeMenu = menu; } function bindNodeInteraction(node, login, onLongPress) { let timerId = 0; let startX = 0; let startY = 0; let longPressTriggered = false; const clearTimer = () => { if (!timerId) return; window.clearTimeout(timerId); timerId = 0; }; node.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; startX = event.clientX; startY = event.clientY; longPressTriggered = false; clearTimer(); timerId = window.setTimeout(async () => { longPressTriggered = true; closeNodeMenu(); await onLongPress(login); }, 500); }); node.addEventListener('pointermove', (event) => { if (!timerId) return; const dx = Math.abs(event.clientX - startX); const dy = Math.abs(event.clientY - startY); if (dx > 8 || dy > 8) clearTimer(); }); node.addEventListener('pointerleave', clearTimer); node.addEventListener('pointercancel', clearTimer); node.addEventListener('pointerup', (event) => { if (event.button !== 0) return; clearTimer(); if (longPressTriggered) return; openNodeMenu(node, login); }); } async function load(nextCenterLogin = '') { const requestId = ++loadSeq; const targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login); centerLogin = targetCenter; closeNodeMenu(); note.textContent = 'Загрузка связей...'; try { const graph = await authService.getUserConnectionsGraph(targetCenter); if (requestId !== loadSeq) return; 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, mark: nodeModel.mark, }); node.style.left = `${nodeModel.x}%`; node.style.top = `${nodeModel.y}%`; board.append(node); nodeElements.set(nodeModel.id, node); if (!nodeModel.isCenter) bindNodeInteraction(node, nodeModel.login, load); }); redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges); requestAnimationFrame(() => redrawEdges()); note.textContent = 'Тап по узлу: меню «Показать информацию» или «Показать связи». Долгое нажатие: сделать узел центром.'; } catch (error) { if (requestId !== loadSeq) return; note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`; } } const outsideTapHandler = (event) => { if (!activeMenu) return; if (!(event.target instanceof Node)) return; if (activeMenu.contains(event.target)) return; closeNodeMenu(); }; document.addEventListener('pointerdown', outsideTapHandler, true); const onResize = () => redrawEdges(); window.addEventListener('resize', onResize); let observer = null; if (typeof ResizeObserver !== 'undefined') { observer = new ResizeObserver(() => redrawEdges()); observer.observe(board); } screen.cleanup = () => { document.removeEventListener('pointerdown', outsideTapHandler, true); window.removeEventListener('resize', onResize); if (observer) observer.disconnect(); }; board.addEventListener('pointerdown', (event) => { const target = event.target; if (!(target instanceof Element)) return; if (target.closest('.node')) return; if (target.closest('.node-menu')) return; closeNodeMenu(); }); load(); screen.append(renderHeader({ title: 'Связи' }), legend, board, note); return screen; }