SHiNE-server/shine-UI/js/pages/network-view.js
AidarKC 4a92a7fa22 Добавлены родственные связи, расширен граф связей и улучшен локальный запуск
Что добавлено:\n- Новые типы CONNECTION для родственников: parent/child/sibling (50/51, 52/53, 54/55) в blockchain/db слоях.\n- Обновлены проверки ConnectionBody и DB-триггер connections_state для корректной записи/удаления новых связей.\n- В профиле добавлен блок "Близкие родственники" с модальным выбором типа связи и логина; добавление через AddBlock для parent/child/sibling.\n- Расширен API GetUserConnectionsGraph: out/in списки для родителей/детей/сиблингов, агрегированные списки родственников с полом, список allUsers с метками официальный/сияющий.\n- Полностью обновлен UI страницы "Связи": новое позиционирование родственников вокруг центра, отдельный цвет родственных связей, линия для взаимных связей и стрелка для односторонних, корректная геометрия линий при ресайзе.\n- Добавлена Gradle-задача startLocalWithBuild для запуска локального стека после build; сохранена отдельная startLocal без полного build.
2026-04-17 21:01:53 +03:00

530 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '<button class="ghost-btn" type="button">Показать информацию о пользователе</button>';
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 btn = menu.querySelector('button');
btn?.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
closeNodeMenu();
});
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;
}