SHA256
Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами. Движок (js/pages/network/force-graph.js): - diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте); - мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса; - динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски; - жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи; - линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией; - хард-лимит DOM-аватарок (остальное — SVG-точки). Интеграция и UX: - adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only); - фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль; - прицел в центре, дыхание фокуса, свечение сияющих; - лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда. Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md. Бамп client.version 1.2.135 -> 1.2.136. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
885cf463a7
commit
f56e531384
+132
-416
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
import { renderNetworkLab } from './network/lab.js';
|
||||
import { createForceGraph } from './network/force-graph.js';
|
||||
import { engineModelFromGraphModel } from './network/adapter.js';
|
||||
import { openNodeMenu, relationLabelRu } from './network/node-menu.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();
|
||||
@@ -111,147 +112,6 @@ function getRelativeGenderMap(graph) {
|
||||
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);
|
||||
@@ -264,6 +124,13 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const inSiblings = toSet(graph?.inSiblings);
|
||||
const outSpouses = toSet(graph?.outSpouses);
|
||||
const inSpouses = toSet(graph?.inSpouses);
|
||||
// контакты/подписки/знакомые — для слоя «Все контакты» (Фаза 3)
|
||||
const outContacts = toSet(graph?.outContacts);
|
||||
const inContacts = toSet(graph?.inContacts);
|
||||
const outFollows = toSet(graph?.outFollows);
|
||||
const inFollows = toSet(graph?.inFollows);
|
||||
const outKnown = toSet(graph?.outKnownPersons);
|
||||
const inKnown = toSet(graph?.inKnownPersons);
|
||||
|
||||
const relativesGender = getRelativeGenderMap(graph);
|
||||
const allMarks = getMarkByLogin(graph?.allUsers);
|
||||
@@ -279,6 +146,12 @@ function buildGraphModel(graph, centerLogin) {
|
||||
...(graph?.inSiblings || []),
|
||||
...(graph?.outSpouses || []),
|
||||
...(graph?.inSpouses || []),
|
||||
...(graph?.outContacts || []),
|
||||
...(graph?.inContacts || []),
|
||||
...(graph?.outFollows || []),
|
||||
...(graph?.inFollows || []),
|
||||
...(graph?.outKnownPersons || []),
|
||||
...(graph?.inKnownPersons || []),
|
||||
]).filter((entry) => normKey(entry) !== normKey(login));
|
||||
|
||||
const relations = allLogins.map((targetLogin) => {
|
||||
@@ -292,12 +165,15 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const spouseIn = hasLogin(inSpouses, targetLogin);
|
||||
const friendOut = hasLogin(outFriends, targetLogin);
|
||||
const friendIn = hasLogin(inFriends, targetLogin);
|
||||
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
|
||||
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, targetLogin);
|
||||
|
||||
let role = 'friend';
|
||||
let role = 'contact';
|
||||
if (parentOut || parentIn) role = 'parent';
|
||||
else if (childOut || childIn) role = 'child';
|
||||
else if (spouseOut || spouseIn) role = 'spouse';
|
||||
else if (siblingOut || siblingIn) role = 'sibling';
|
||||
else if (friendOut || friendIn) role = 'friend';
|
||||
|
||||
let forward = friendOut;
|
||||
let backward = friendIn;
|
||||
@@ -313,13 +189,16 @@ function buildGraphModel(graph, centerLogin) {
|
||||
} else if (role === 'sibling') {
|
||||
forward = siblingOut;
|
||||
backward = siblingIn;
|
||||
} else if (role === 'contact') {
|
||||
forward = contactOut;
|
||||
backward = contactIn;
|
||||
}
|
||||
|
||||
return {
|
||||
login: targetLogin,
|
||||
key: normKey(targetLogin),
|
||||
role,
|
||||
isRelative: role !== 'friend',
|
||||
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
|
||||
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
|
||||
forward: Boolean(forward),
|
||||
backward: Boolean(backward),
|
||||
@@ -334,188 +213,14 @@ function buildGraphModel(graph, centerLogin) {
|
||||
};
|
||||
}
|
||||
|
||||
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 }) {
|
||||
// Лабораторный режим force-графа (Фаза 1): рендерится из мока, не трогая реальный путь ниже.
|
||||
if (String(route?.params?.mode || '').trim().toLowerCase() === 'lab') {
|
||||
return renderNetworkLab({ navigate });
|
||||
}
|
||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||
if (!keepHistory) {
|
||||
@@ -532,14 +237,35 @@ export function render({ navigate, route }) {
|
||||
stage.className = 'network-stage';
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'network-board network-board--full';
|
||||
board.className = 'network-board network-board--full fg-stage';
|
||||
|
||||
const profileCardCache = new Map();
|
||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||
let redrawEdges = () => {};
|
||||
let engine = null;
|
||||
let sheetEl = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||
const FILTERS = {
|
||||
all: { label: 'Все', pred: () => true },
|
||||
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
|
||||
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
|
||||
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
||||
};
|
||||
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
||||
let activeFilter = 'all';
|
||||
const filterChips = {};
|
||||
|
||||
function applyFilter(key) {
|
||||
if (!FILTERS[key]) return;
|
||||
activeFilter = key;
|
||||
FILTER_ORDER.forEach((k) => {
|
||||
const el = filterChips[k];
|
||||
if (el) el.classList.toggle('is-active', k === activeFilter);
|
||||
});
|
||||
if (engine) engine.setFilter(FILTERS[key].pred);
|
||||
}
|
||||
|
||||
function profileInfoRoute(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
@@ -692,62 +418,69 @@ export function render({ navigate, route }) {
|
||||
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));
|
||||
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
|
||||
function showNodeSheet(node) {
|
||||
if (!sheetEl) {
|
||||
sheetEl = document.createElement('div');
|
||||
sheetEl.className = 'fg-sheet';
|
||||
stage.append(sheetEl);
|
||||
}
|
||||
return profileCardCache.get(key);
|
||||
const login = normalizeLogin(node.login);
|
||||
const shineBadge = node.shining ? '<span class="fg-sheet-badge">сияющий</span>' : '';
|
||||
sheetEl.innerHTML = `
|
||||
<button class="fg-sheet-close" type="button" data-act="close" aria-label="Закрыть">✕</button>
|
||||
<div class="fg-sheet-body">
|
||||
<div class="fg-sheet-title">${escapeHtml(login)} ${shineBadge}</div>
|
||||
<div class="fg-sheet-rel">${escapeHtml(relationLabelRu(node.relationType))}</div>
|
||||
</div>
|
||||
<div class="fg-sheet-actions">
|
||||
<button class="ghost-btn" type="button" data-act="profile">Профиль</button>
|
||||
<button class="primary-btn" type="button" data-act="write">Написать</button>
|
||||
</div>
|
||||
`;
|
||||
sheetEl.classList.add('is-open');
|
||||
sheetEl.onclick = (e) => {
|
||||
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'close') hideNodeSheet();
|
||||
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
|
||||
else if (act === 'write') navigate(`chat-view/${encodeURIComponent(login)}`);
|
||||
};
|
||||
}
|
||||
|
||||
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 hideNodeSheet() {
|
||||
if (sheetEl) sheetEl.classList.remove('is-open');
|
||||
}
|
||||
|
||||
function bindNodeInteraction(node, nodeModel) {
|
||||
node.addEventListener('click', () => {
|
||||
if (nodeModel.isCenter) {
|
||||
const routeTo = profileInfoRoute(nodeModel.login);
|
||||
function ensureEngine(model) {
|
||||
if (engine) {
|
||||
engine.setModel(model);
|
||||
return;
|
||||
}
|
||||
engine = createForceGraph({
|
||||
stage: board,
|
||||
model,
|
||||
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
|
||||
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
|
||||
// тап по центру — полноценный профиль
|
||||
onCenterTap: (node) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
if (routeTo) navigate(routeTo);
|
||||
return;
|
||||
}
|
||||
void load(nodeModel.login, { pushHistory: true });
|
||||
},
|
||||
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
||||
onNodeLongPress: (node, point) => {
|
||||
const login = normalizeLogin(node.login);
|
||||
openNodeMenu({
|
||||
login,
|
||||
relationType: node.relationType,
|
||||
point,
|
||||
actions: [
|
||||
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } },
|
||||
{ label: 'Написать', onClick: () => navigate(`chat-view/${encodeURIComponent(login)}`) },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -765,34 +498,12 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||
|
||||
const model = buildGraphModel(graph, targetCenter);
|
||||
const layout = layoutNodes(model);
|
||||
const graphModel = buildGraphModel(graph, targetCenter);
|
||||
const engineModel = engineModelFromGraphModel(graphModel);
|
||||
ensureEngine(engineModel);
|
||||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
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) {
|
||||
@@ -823,18 +534,10 @@ export function render({ navigate, route }) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (observer) observer.disconnect();
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
@@ -857,8 +560,21 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
FILTER_ORDER.forEach((key) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
|
||||
chip.textContent = FILTERS[key].label;
|
||||
chip.addEventListener('click', () => applyFilter(key));
|
||||
filterChips[key] = chip;
|
||||
filterBar.append(chip);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header);
|
||||
stage.append(board, header, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user