SHA256
818 lines
31 KiB
JavaScript
818 lines
31 KiB
JavaScript
import { createTopBar } from '../components/topbar.js';
|
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
|
import { authService, state } from '../state.js';
|
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
|
import { createForceGraph } from './network/force-graph.js';
|
|
import { engineModelFromGraphModel } from './network/adapter.js';
|
|
import { openNodeMenu } from './network/node-menu.js';
|
|
import { userDisplayName } from '../services/user-display.js';
|
|
|
|
export const pageMeta = {
|
|
id: 'network-view',
|
|
title: 'Связи',
|
|
shellMode: {
|
|
topFade: true,
|
|
bottomFade: true,
|
|
bottomFadeAnchor: 'toolbar',
|
|
fadeProfile: 'edge',
|
|
contentUnderTopbar: true,
|
|
scrollContainer: 'locked',
|
|
},
|
|
};
|
|
|
|
const GENDER_MALE = 'male';
|
|
const GENDER_FEMALE = 'female';
|
|
const GENDER_UNKNOWN = 'unknown';
|
|
|
|
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,
|
|
firstName: String(row?.firstName || '').trim(),
|
|
lastName: String(row?.lastName || '').trim(),
|
|
displayName: userDisplayName({ login, firstName: row?.firstName, lastName: row?.lastName }),
|
|
relationType: String(row?.relationType || '').trim().toLowerCase(),
|
|
primaryConfirmed: Boolean(row?.primaryConfirmed),
|
|
shineConfirmed: Boolean(row?.shineConfirmed),
|
|
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();
|
|
// Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды.
|
|
void graph;
|
|
return map;
|
|
}
|
|
|
|
function buildGraphModel(graph, centerLogin) {
|
|
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
|
|
const outFriends = toSet(graph?.outFriends);
|
|
const inFriends = toSet(graph?.inFriends);
|
|
const outCloseFriends = toSet(graph?.outCloseFriends);
|
|
const inCloseFriends = toSet(graph?.inCloseFriends);
|
|
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);
|
|
// контакты/подписки/знакомые — для слоя «Все контакты» (Фаза 3)
|
|
const outContacts = toSet(graph?.outContacts);
|
|
const inContacts = toSet(graph?.inContacts);
|
|
const outFollows = toSet(graph?.outFollows);
|
|
const inFollows = toSet(graph?.inFollows);
|
|
const outOfficial = toSet(graph?.outOfficialAccounts);
|
|
const inOfficial = toSet(graph?.inOfficialAccounts);
|
|
|
|
const relativesGender = getRelativeGenderMap(graph);
|
|
const allMarks = getMarkByLogin(graph?.allUsers);
|
|
|
|
const allLogins = uniqueLogins([
|
|
...(graph?.outFriends || []),
|
|
...(graph?.inFriends || []),
|
|
...(graph?.outCloseFriends || []),
|
|
...(graph?.inCloseFriends || []),
|
|
...(graph?.outContacts || []),
|
|
...(graph?.inContacts || []),
|
|
...(graph?.outFollows || []),
|
|
...(graph?.inFollows || []),
|
|
...(graph?.outOfficialAccounts || []),
|
|
...(graph?.inOfficialAccounts || []),
|
|
]).filter((entry) => normKey(entry) !== normKey(login));
|
|
|
|
const relations = allLogins.map((targetLogin) => {
|
|
const friendOut = hasLogin(outFriends, targetLogin);
|
|
const friendIn = hasLogin(inFriends, targetLogin);
|
|
const closeFriendOut = hasLogin(outCloseFriends, targetLogin);
|
|
const closeFriendIn = hasLogin(inCloseFriends, targetLogin);
|
|
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outOfficial, targetLogin);
|
|
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inOfficial, targetLogin);
|
|
|
|
let role = 'contact';
|
|
if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend';
|
|
|
|
let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut;
|
|
let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn;
|
|
|
|
return {
|
|
login: targetLogin,
|
|
key: normKey(targetLogin),
|
|
role,
|
|
isRelative: false,
|
|
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,
|
|
};
|
|
}
|
|
|
|
let persistedCenterLogin = '';
|
|
let persistedCenterHistory = [];
|
|
let persistedGraphHistory = [];
|
|
|
|
const HISTORY_MAX_CENTERS = 4;
|
|
const HISTORY_CENTER_GAP_MIN = 280;
|
|
const HISTORY_CENTER_GAP_STEP = 72;
|
|
const HISTORY_CENTER_CLEARANCE = 110;
|
|
const HISTORY_ORBIT_FIRST_R = 104;
|
|
const HISTORY_ORBIT_GAP = 88;
|
|
const HISTORY_ORBIT_NODE_GAP = 84;
|
|
const HISTORY_NODE_CLEARANCE = 92;
|
|
|
|
function historyHash01(value) {
|
|
let h = 2166136261;
|
|
const text = String(value || '');
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
h ^= text.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return ((h >>> 0) % 1000) / 1000;
|
|
}
|
|
|
|
function historyOrbitCapacity(radius) {
|
|
const r = Math.max(radius, HISTORY_ORBIT_NODE_GAP / 2 + 1);
|
|
const minAngle = 2 * Math.asin(Math.min(1, HISTORY_ORBIT_NODE_GAP / (2 * r)));
|
|
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
|
|
}
|
|
|
|
function historyOrbitPlacements(total, seed = '') {
|
|
const count = Math.max(0, Number(total) || 0);
|
|
const out = new Array(count);
|
|
let start = 0;
|
|
let ring = 0;
|
|
const seedPhase = historyHash01(seed) * Math.PI * 2;
|
|
while (start < count) {
|
|
const radius = HISTORY_ORBIT_FIRST_R + ring * HISTORY_ORBIT_GAP;
|
|
const capacity = historyOrbitCapacity(radius);
|
|
const ringCount = Math.min(capacity, count - start);
|
|
const phase = seedPhase + ring * 0.43;
|
|
for (let i = 0; i < ringCount; i += 1) {
|
|
out[start + i] = {
|
|
radius,
|
|
angle: phase + (Math.PI * 2 * i) / Math.max(1, ringCount),
|
|
};
|
|
}
|
|
start += ringCount;
|
|
ring += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function historyClusterRadius(snapshot) {
|
|
const nodes = Array.isArray(snapshot?.engineModel?.nodes) ? snapshot.engineModel.nodes : [];
|
|
const centerKey = normKey(snapshot?.centerLogin);
|
|
const relationCount = nodes.filter((node) => normKey(node?.id) !== centerKey).length;
|
|
const placements = historyOrbitPlacements(relationCount, snapshot?.centerLogin || '');
|
|
const maxOrbit = placements.reduce((max, row) => Math.max(max, Number(row?.radius) || 0), 0);
|
|
return Math.max(HISTORY_ORBIT_FIRST_R, maxOrbit) + HISTORY_CENTER_CLEARANCE;
|
|
}
|
|
|
|
function buildHistoryEngineModel(history) {
|
|
const snapshots = (Array.isArray(history) ? history : []).slice(-HISTORY_MAX_CENTERS);
|
|
const latest = snapshots[snapshots.length - 1];
|
|
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
|
|
|
const centerPos = new Map();
|
|
const centerOrder = new Map();
|
|
const clusterRadius = new Map();
|
|
centerPos.set(normKey(latest.centerLogin), { x: 0, y: 0 });
|
|
snapshots.forEach((snap, index) => {
|
|
const key = normKey(snap.centerLogin);
|
|
centerOrder.set(key, index);
|
|
clusterRadius.set(key, historyClusterRadius(snap));
|
|
});
|
|
|
|
// Новейший центр имеет приоритет. Более старые центры отодвигаем вдоль направления перехода
|
|
// настолько далеко, насколько нужно, чтобы окружности их кластеров не пересекались.
|
|
for (let i = snapshots.length - 1; i > 0; i -= 1) {
|
|
const current = snapshots[i];
|
|
const previous = snapshots[i - 1];
|
|
const currentKey = normKey(current.centerLogin);
|
|
const previousKey = normKey(previous.centerLogin);
|
|
const curPos = centerPos.get(currentKey) || { x: 0, y: 0 };
|
|
const angle = Number.isFinite(Number(current.transitionAngle)) ? Number(current.transitionAngle) : 0;
|
|
const prevR = clusterRadius.get(previousKey) || HISTORY_CENTER_CLEARANCE;
|
|
const curR = clusterRadius.get(currentKey) || HISTORY_CENTER_CLEARANCE;
|
|
let gap = Math.max(HISTORY_CENTER_GAP_MIN, prevR + curR);
|
|
let candidate = null;
|
|
|
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
|
candidate = {
|
|
x: curPos.x - Math.cos(angle) * gap,
|
|
y: curPos.y - Math.sin(angle) * gap,
|
|
};
|
|
let collides = false;
|
|
for (const [placedKey, placed] of centerPos.entries()) {
|
|
const placedR = clusterRadius.get(placedKey) || HISTORY_CENTER_CLEARANCE;
|
|
const minDist = prevR + placedR;
|
|
if (Math.hypot(candidate.x - placed.x, candidate.y - placed.y) < minDist) {
|
|
collides = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!collides) break;
|
|
gap += HISTORY_CENTER_GAP_STEP;
|
|
}
|
|
centerPos.set(previousKey, candidate || {
|
|
x: curPos.x - Math.cos(angle) * gap,
|
|
y: curPos.y - Math.sin(angle) * gap,
|
|
});
|
|
}
|
|
|
|
const nodeMap = new Map();
|
|
const latestPlacementParent = new Map();
|
|
const edgeMap = new Map();
|
|
|
|
// Сначала собираем все узлы/рёбра, не назначая окончательные позиции периферийным пользователям.
|
|
snapshots.forEach((snap, snapIndex) => {
|
|
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
|
const centerKey = normKey(snap.centerLogin);
|
|
const center = modelNodes.find((node) => normKey(node?.id) === centerKey) || modelNodes[0];
|
|
if (center) {
|
|
nodeMap.set(centerKey, { ...nodeMap.get(centerKey), ...center, keepVisible: true, isHistoryCenter: true });
|
|
}
|
|
|
|
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
|
relations.forEach((node) => {
|
|
const key = normKey(node?.id);
|
|
if (!key) return;
|
|
nodeMap.set(key, { ...nodeMap.get(key), ...node });
|
|
|
|
const a = centerKey;
|
|
const b = key;
|
|
const edgeKey = a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
edgeMap.set(edgeKey, {
|
|
a,
|
|
b,
|
|
relationType: node.relationType || 'contact',
|
|
strength: Number(node.strength) || 0.5,
|
|
snapIndex,
|
|
});
|
|
});
|
|
});
|
|
|
|
// Все исторические центры сразу считаются занятыми местами.
|
|
const occupied = [];
|
|
for (const [centerKey, pos] of centerPos.entries()) {
|
|
occupied.push({ key: centerKey, x: pos.x, y: pos.y, clearance: HISTORY_CENTER_CLEARANCE });
|
|
}
|
|
|
|
const collidesAt = (key, x, y, clearance = HISTORY_NODE_CLEARANCE) => occupied.some((row) => {
|
|
if (row.key === key) return false;
|
|
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
|
|
});
|
|
|
|
// Новые/актуальные круги имеют приоритет: идём от текущего центра к старым. Если пользователь
|
|
// встречается повторно, его единственный узел получает позицию именно в наиболее свежем круге.
|
|
for (let snapIndex = snapshots.length - 1; snapIndex >= 0; snapIndex -= 1) {
|
|
const snap = snapshots[snapIndex];
|
|
const modelNodes = Array.isArray(snap.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
|
const centerKey = normKey(snap.centerLogin);
|
|
const relations = modelNodes.filter((node) => normKey(node?.id) !== centerKey);
|
|
const placements = historyOrbitPlacements(relations.length, snap.centerLogin);
|
|
const base = centerPos.get(centerKey) || { x: 0, y: 0 };
|
|
|
|
relations.forEach((node, index) => {
|
|
const key = normKey(node?.id);
|
|
if (!key || centerOrder.has(key) || latestPlacementParent.has(key)) return;
|
|
const preferred = placements[index] || { radius: HISTORY_ORBIT_FIRST_R, angle: 0 };
|
|
let chosen = null;
|
|
// Сначала пробуем желаемую орбиту, затем соседние углы, после чего постепенно расширяем радиус.
|
|
for (let radialStep = 0; radialStep < 12 && !chosen; radialStep += 1) {
|
|
const radius = preferred.radius + radialStep * HISTORY_ORBIT_GAP * 0.55;
|
|
const angularSamples = 18 + radialStep * 2;
|
|
for (let step = 0; step < angularSamples; step += 1) {
|
|
const offsetIndex = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? 1 : -1);
|
|
const angle = preferred.angle + offsetIndex * (Math.PI * 2 / angularSamples);
|
|
const x = base.x + Math.cos(angle) * radius;
|
|
const y = base.y + Math.sin(angle) * radius;
|
|
if (!collidesAt(key, x, y, HISTORY_NODE_CLEARANCE)) {
|
|
chosen = { x, y };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!chosen) {
|
|
const radius = preferred.radius + 12 * HISTORY_ORBIT_GAP * 0.55;
|
|
chosen = {
|
|
x: base.x + Math.cos(preferred.angle) * radius,
|
|
y: base.y + Math.sin(preferred.angle) * radius,
|
|
};
|
|
}
|
|
latestPlacementParent.set(key, { centerKey, ...chosen, snapIndex });
|
|
occupied.push({ key, ...chosen, clearance: HISTORY_NODE_CLEARANCE });
|
|
});
|
|
}
|
|
|
|
for (const [centerKey, pos] of centerPos.entries()) {
|
|
const node = nodeMap.get(centerKey);
|
|
if (!node) continue;
|
|
nodeMap.set(centerKey, {
|
|
...node,
|
|
layoutX: pos.x,
|
|
layoutY: pos.y,
|
|
fixedLayout: true,
|
|
keepVisible: true,
|
|
isHistoryCenter: true,
|
|
});
|
|
}
|
|
|
|
for (const [key, placement] of latestPlacementParent.entries()) {
|
|
const node = nodeMap.get(key);
|
|
if (!node || centerOrder.has(key)) continue;
|
|
nodeMap.set(key, {
|
|
...node,
|
|
layoutX: placement.x,
|
|
layoutY: placement.y,
|
|
fixedLayout: true,
|
|
parentId: placement.centerKey,
|
|
});
|
|
}
|
|
|
|
const edgeParentsByChild = new Map();
|
|
const addEdgeParent = (childKey, parentKey, edge) => {
|
|
if (!childKey || !parentKey || childKey === parentKey) return;
|
|
const list = edgeParentsByChild.get(childKey) || [];
|
|
if (!list.some((row) => row.id === parentKey)) {
|
|
list.push({
|
|
id: parentKey,
|
|
relationType: edge.relationType,
|
|
strength: edge.strength,
|
|
shining: edge.shining,
|
|
});
|
|
}
|
|
edgeParentsByChild.set(childKey, list);
|
|
};
|
|
|
|
for (const edge of edgeMap.values()) {
|
|
const aCenterIndex = centerOrder.get(edge.a);
|
|
const bCenterIndex = centerOrder.get(edge.b);
|
|
if (aCenterIndex !== undefined && bCenterIndex !== undefined) {
|
|
if (aCenterIndex > bCenterIndex) addEdgeParent(edge.a, edge.b, edge);
|
|
else addEdgeParent(edge.b, edge.a, edge);
|
|
continue;
|
|
}
|
|
if (aCenterIndex !== undefined) addEdgeParent(edge.b, edge.a, edge);
|
|
else if (bCenterIndex !== undefined) addEdgeParent(edge.a, edge.b, edge);
|
|
}
|
|
|
|
const nodes = [...nodeMap.entries()].map(([key, node]) => ({
|
|
...node,
|
|
id: key,
|
|
login: node.login || node.id || key,
|
|
tier: 1,
|
|
fixedLayout: true,
|
|
edgeParents: edgeParentsByChild.get(key) || [],
|
|
}));
|
|
|
|
return {
|
|
focusId: normKey(latest.engineModel.focusId),
|
|
nodes,
|
|
preserveHistory: snapshots.length > 1,
|
|
};
|
|
}
|
|
|
|
export function render({ navigate, route, chrome } = {}) {
|
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
|
if (!keepHistory) {
|
|
persistedCenterLogin = '';
|
|
persistedCenterHistory = [];
|
|
persistedGraphHistory = [];
|
|
}
|
|
|
|
const screen = document.createElement('section');
|
|
screen.className = 'network-screen';
|
|
|
|
const stage = document.createElement('div');
|
|
stage.className = 'network-stage';
|
|
|
|
const board = document.createElement('div');
|
|
board.className = 'network-board network-board--full fg-stage';
|
|
|
|
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
|
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
|
let graphHistory = Array.isArray(persistedGraphHistory) ? [...persistedGraphHistory] : [];
|
|
let engine = null;
|
|
let loadSeq = 0;
|
|
|
|
// Независимые фильтры карты. Оба выключены = показываем всё.
|
|
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
|
|
const FILTERS = {
|
|
close: { label: 'Близкие', pred: (n) => n.relationType === 'close_friend' },
|
|
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
|
|
};
|
|
const FILTER_ORDER = ['close', 'shining'];
|
|
const activeFilters = new Set();
|
|
const filterChips = {};
|
|
|
|
function currentFilterPredicate(node) {
|
|
for (const key of activeFilters) {
|
|
if (!FILTERS[key].pred(node)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function applyFilter(key) {
|
|
if (!FILTERS[key]) return;
|
|
if (activeFilters.has(key)) activeFilters.delete(key);
|
|
else activeFilters.add(key);
|
|
FILTER_ORDER.forEach((k) => {
|
|
const el = filterChips[k];
|
|
if (el) el.classList.toggle('is-active', activeFilters.has(k));
|
|
});
|
|
if (engine) engine.setFilter(currentFilterPredicate);
|
|
}
|
|
|
|
function profileInfoRoute(login) {
|
|
const cleanLogin = normalizeLogin(login);
|
|
if (!cleanLogin) return '';
|
|
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
|
return makeProfileRoute(cleanLogin);
|
|
}
|
|
|
|
function persistHistory() {
|
|
persistedCenterLogin = centerLogin;
|
|
persistedCenterHistory = [...centerHistory];
|
|
persistedGraphHistory = [...graphHistory];
|
|
}
|
|
|
|
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 openSearchModal() {
|
|
const root = document.getElementById('modal-root');
|
|
if (!(root instanceof HTMLElement)) return;
|
|
root.innerHTML = `
|
|
<div class="modal" id="network-search-modal">
|
|
<div class="modal-card stack">
|
|
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
|
<h3 class="modal-title">Найти пользователя</h3>
|
|
<div class="row" style="gap:8px;">
|
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
|
</div>
|
|
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div>
|
|
<div class="stack" id="network-search-results"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const modal = root.querySelector('#network-search-modal');
|
|
const closeBtn = root.querySelector('#network-search-close');
|
|
const inputEl = root.querySelector('#network-search-input');
|
|
const runBtn = root.querySelector('#network-search-run');
|
|
const metaEl = root.querySelector('#network-search-meta');
|
|
const resultsEl = root.querySelector('#network-search-results');
|
|
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
|
root.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let selectedLogin = '';
|
|
let searchSeq = 0;
|
|
|
|
const close = () => {
|
|
root.innerHTML = '';
|
|
};
|
|
|
|
const applySelection = (login) => {
|
|
selectedLogin = normalizeLogin(login);
|
|
const rows = resultsEl.querySelectorAll('[data-candidate]');
|
|
rows.forEach((row) => {
|
|
if (!(row instanceof HTMLElement)) return;
|
|
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
|
});
|
|
};
|
|
|
|
const renderCandidates = (logins) => {
|
|
const items = (Array.isArray(logins) ? logins : [])
|
|
.map((item) => normalizeLogin(item))
|
|
.filter(Boolean)
|
|
.slice(0, 5);
|
|
if (!items.length) {
|
|
resultsEl.innerHTML = '<div class="meta-muted">Кандидаты не найдены.</div>';
|
|
applySelection('');
|
|
return;
|
|
}
|
|
resultsEl.innerHTML = items.map((login) => (
|
|
`<button type="button" class="ghost-btn network-search-candidate" data-candidate="${escapeHtml(login)}">${escapeHtml(login)}</button>`
|
|
)).join('');
|
|
applySelection('');
|
|
};
|
|
|
|
const runSearch = async () => {
|
|
const query = normalizeLogin(inputEl.value);
|
|
if (!query) {
|
|
metaEl.textContent = 'Введите логин.';
|
|
renderCandidates([]);
|
|
return;
|
|
}
|
|
const reqId = ++searchSeq;
|
|
metaEl.textContent = `Поиск по «${query}»...`;
|
|
if (runBtn instanceof HTMLButtonElement) runBtn.disabled = true;
|
|
try {
|
|
const found = await authService.searchUsers(query);
|
|
if (reqId !== searchSeq) return;
|
|
renderCandidates(found);
|
|
const foundCount = Math.min(5, Array.isArray(found) ? found.length : 0);
|
|
metaEl.textContent = foundCount > 0
|
|
? `Найдено кандидатов: ${foundCount}. Выберите одного.`
|
|
: 'Кандидаты не найдены.';
|
|
} catch (error) {
|
|
if (reqId !== searchSeq) return;
|
|
renderCandidates([]);
|
|
metaEl.textContent = `Ошибка поиска: ${error?.message || 'unknown'}`;
|
|
} finally {
|
|
if (runBtn instanceof HTMLButtonElement) runBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
modal.addEventListener('click', (event) => {
|
|
if (event.target === modal) close();
|
|
});
|
|
closeBtn?.addEventListener('click', close);
|
|
runBtn?.addEventListener('click', () => { void runSearch(); });
|
|
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
|
|
inputEl.addEventListener('input', debouncedSearch);
|
|
inputEl.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void runSearch();
|
|
}
|
|
});
|
|
resultsEl.addEventListener('click', (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) return;
|
|
const button = target.closest('[data-candidate]');
|
|
if (!(button instanceof HTMLElement)) return;
|
|
const nextLogin = String(button.dataset.candidate || '');
|
|
applySelection(nextLogin);
|
|
if (!nextLogin) return;
|
|
close();
|
|
void load(nextLogin, { pushHistory: true });
|
|
});
|
|
|
|
window.setTimeout(() => inputEl.focus(), 0);
|
|
}
|
|
|
|
function ensureEngine(model) {
|
|
if (engine) {
|
|
engine.setModel(model);
|
|
return;
|
|
}
|
|
engine = createForceGraph({
|
|
stage: board,
|
|
model,
|
|
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
|
|
onNodeTap: (node) => {
|
|
const transitionAngle = Math.atan2(Number(node?.y) || 0, Number(node?.x) || 0);
|
|
void load(node.login, { pushHistory: true, transitionAngle });
|
|
},
|
|
// тап по центру — полноценный профиль
|
|
onCenterTap: (node) => {
|
|
const routeTo = profileInfoRoute(node.login);
|
|
if (routeTo) navigate(routeTo);
|
|
},
|
|
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
|
onNodeLongPress: (node, point) => {
|
|
const login = normalizeLogin(node.login);
|
|
openNodeMenu({
|
|
login,
|
|
displayName: String(node.name || '').trim(),
|
|
relationType: node.relationType,
|
|
point,
|
|
actions: [
|
|
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } },
|
|
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(login)}`) },
|
|
],
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
async function load(nextCenterLogin = '', { pushHistory = false, transitionAngle = 0 } = {}) {
|
|
const requestId = ++loadSeq;
|
|
const prevCenter = centerLogin;
|
|
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
|
|
|
try {
|
|
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
|
if (requestId !== loadSeq) return;
|
|
centerLogin = targetCenter;
|
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
|
centerHistory.push(prevCenter);
|
|
}
|
|
syncLinksUrl(targetCenter, { push: pushHistory });
|
|
|
|
const graphModel = buildGraphModel(graph, targetCenter);
|
|
const snapshotModel = engineModelFromGraphModel(graphModel);
|
|
const snapshot = {
|
|
centerLogin: targetCenter,
|
|
engineModel: snapshotModel,
|
|
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
|
|
};
|
|
|
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
|
// Один и тот же пользователь не хранится как два исторических центра: повторный переход
|
|
// переносит его в конец истории, где он становится актуальным центром.
|
|
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
|
|
graphHistory.push(snapshot);
|
|
if (graphHistory.length > HISTORY_MAX_CENTERS) {
|
|
graphHistory = graphHistory.slice(-HISTORY_MAX_CENTERS);
|
|
centerHistory = centerHistory.slice(-(HISTORY_MAX_CENTERS - 1));
|
|
}
|
|
} else {
|
|
const last = graphHistory[graphHistory.length - 1];
|
|
if (last && normKey(last.centerLogin) === normKey(targetCenter)) {
|
|
snapshot.transitionAngle = Number(last.transitionAngle) || 0;
|
|
graphHistory[graphHistory.length - 1] = snapshot;
|
|
} else {
|
|
graphHistory = [snapshot];
|
|
}
|
|
}
|
|
|
|
const engineModel = buildHistoryEngineModel(graphHistory);
|
|
ensureEngine(engineModel);
|
|
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
|
|
|
persistHistory();
|
|
} catch (error) {
|
|
if (requestId !== loadSeq) return;
|
|
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
|
}
|
|
}
|
|
|
|
const searchIconHtml = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="6.5"></circle>
|
|
<path d="M16 16l4 4"></path>
|
|
</svg>
|
|
`;
|
|
const header = createTopBar({
|
|
title: 'Связи',
|
|
actions: [
|
|
{
|
|
iconNode: createOverflowDots(),
|
|
title: 'Меню связей',
|
|
ariaLabel: 'Открыть меню связей',
|
|
className: 'chat-header-icon-btn network-header-menu-btn',
|
|
menu: {
|
|
minWidth: 220,
|
|
items: [
|
|
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
|
screen.cleanup = () => {
|
|
if (engine) engine.destroy();
|
|
engine = null;
|
|
};
|
|
|
|
if (routeLogin) {
|
|
centerLogin = routeLogin;
|
|
centerHistory = [];
|
|
graphHistory = [];
|
|
persistHistory();
|
|
void load(centerLogin, { pushHistory: false });
|
|
} else if (keepHistory && centerLogin) {
|
|
void load(centerLogin, { pushHistory: false });
|
|
} else {
|
|
centerLogin = normalizeLogin(state.session.login || '');
|
|
centerHistory = [];
|
|
graphHistory = [];
|
|
persistHistory();
|
|
if (centerLogin) {
|
|
void load(centerLogin, { pushHistory: false });
|
|
} else {
|
|
window.setTimeout(() => openSearchModal(), 0);
|
|
}
|
|
}
|
|
|
|
// Панель фильтров слоёв (оверлей под шапкой)
|
|
const filterBar = document.createElement('div');
|
|
filterBar.className = 'fg-filter-bar app-top-tabs';
|
|
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
|
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
FILTER_ORDER.forEach((key) => {
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className = `fg-filter-chip${activeFilters.has(key) ? ' is-active' : ''}`;
|
|
chip.textContent = FILTERS[key].label;
|
|
chip.addEventListener('click', () => applyFilter(key));
|
|
filterChips[key] = chip;
|
|
filterBar.append(chip);
|
|
});
|
|
|
|
chrome?.setTopbar(header);
|
|
stage.append(board, filterBar);
|
|
screen.append(stage);
|
|
return screen;
|
|
}
|