Что сделано:\n- В UI возвращён термин «Близкий друг» без пометки про «друга».\n- На графе связей добавлено меню узла с двумя действиями: «Показать информацию» и «Показать связи» (перенос узла в центр).\n- В модалке добавления связи реализован автопоиск логинов: Enter или пауза 2 секунды, до 5 подсказок, выбор кликом.\n- Добавлены стили для меню узла и списка подсказок.\n- В коде добавлены явные пояснения и alias-константы close friend (без изменения кодов 10/11 и логики):\n CONNECTION_CLOSE_FRIEND / CONNECTION_UNCLOSE_FRIEND.\n- Обработчики чтения/записи связей переключены на alias close friend для лучшей читаемости.
540 lines
17 KiB
JavaScript
540 lines
17 KiB
JavaScript
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';
|
|
node.className = `node ${isCenter ? 'center' : ''} ${kind === 'relative' ? 'is-relative' : 'is-friend'}`.trim();
|
|
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;
|
|
|
|
node.innerHTML = `
|
|
<span class="node-dot">${(login[0] || '?').toUpperCase()}</span>
|
|
<span class="node-label">${login}</span>
|
|
`;
|
|
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 = `
|
|
<span><i class="legend-line friend"></i> Близкие друзья</span>
|
|
<span><i class="legend-line relative"></i> Родственники</span>
|
|
<span><i class="legend-arrow"></i> Односторонняя связь</span>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="node-menu-actions">
|
|
<button class="ghost-btn" type="button" data-menu-action="show-info">Показать информацию</button>
|
|
<button class="ghost-btn" type="button" data-menu-action="show-graph">Показать связи</button>
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
}
|