SHA256
1083 lines
42 KiB
JavaScript
1083 lines
42 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),
|
|
// Основной источник — server official. accountRole оставляем как совместимый fallback,
|
|
// чтобы badge не пропадал на серверах/ответах переходного периода.
|
|
official: row?.official === true
|
|
|| String(row?.official || '').trim().toLowerCase() === 'true'
|
|
|| String(row?.accountRole || '').trim().toLowerCase() === 'primary',
|
|
shine: row?.shine === true
|
|
|| String(row?.shine || '').trim().toLowerCase() === 'true'
|
|
|| String(row?.shineStatus || '').trim().toLowerCase() === 'shining',
|
|
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 = [];
|
|
let persistedHistoryDepth = 4;
|
|
let persistedX2Enabled = false;
|
|
|
|
const HISTORY_MAX_PREVIOUS = 4;
|
|
const HISTORY_CENTER_GAP_START = 108;
|
|
const HISTORY_CENTER_GAP_STEP = 28;
|
|
const HISTORY_CENTER_CLEARANCE = 88;
|
|
const HISTORY_NODE_CLEARANCE = 82;
|
|
const HISTORY_TIER2_CLEARANCE = 48;
|
|
const HISTORY_DIRECT_SPACING = 92;
|
|
const HISTORY_TIER2_SPACING = 54;
|
|
const HISTORY_LAYOUT_MAX_SHELL = 9;
|
|
|
|
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) % 100000) / 100000;
|
|
}
|
|
|
|
function historySquareShellCells(shell) {
|
|
const r = Math.max(1, Math.trunc(shell));
|
|
const cells = [];
|
|
for (let y = -r; y <= r; y += 1) {
|
|
for (let x = -r; x <= r; x += 1) {
|
|
if (Math.max(Math.abs(x), Math.abs(y)) !== r) continue;
|
|
cells.push({ x, y });
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
// Компактная «квадратная» раскладка: сначала заполняем ближайший квадратный пояс,
|
|
// причём соседние выбранные точки стараемся брать далеко друг от друга. Поэтому 3–5 друзей
|
|
// не выстраиваются в длинную цепочку, а образуют небольшое облако вокруг центра.
|
|
function historyCompactSlots(total, seed = '', spacing = HISTORY_DIRECT_SPACING, maxShell = HISTORY_LAYOUT_MAX_SHELL) {
|
|
const count = Math.max(0, Math.trunc(Number(total) || 0));
|
|
const out = [];
|
|
let globalIndex = 0;
|
|
for (let shell = 1; shell <= maxShell && out.length < count; shell += 1) {
|
|
const remaining = historySquareShellCells(shell);
|
|
const ordered = [];
|
|
const start = remaining.length ? Math.floor(historyHash01(`${seed}|${shell}|start`) * remaining.length) : 0;
|
|
if (remaining.length) ordered.push(remaining.splice(start, 1)[0]);
|
|
while (remaining.length) {
|
|
let bestIndex = 0;
|
|
let bestScore = -Infinity;
|
|
remaining.forEach((cell, index) => {
|
|
let minD2 = Infinity;
|
|
for (const used of ordered) {
|
|
const dx = cell.x - used.x;
|
|
const dy = cell.y - used.y;
|
|
minD2 = Math.min(minD2, dx * dx + dy * dy);
|
|
}
|
|
const jitter = historyHash01(`${seed}|${shell}|${cell.x}|${cell.y}`) * 0.05;
|
|
const score = minD2 + jitter;
|
|
if (score > bestScore) { bestScore = score; bestIndex = index; }
|
|
});
|
|
ordered.push(remaining.splice(bestIndex, 1)[0]);
|
|
}
|
|
|
|
const quarterTurns = Math.floor(historyHash01(`${seed}|rotate`) * 4);
|
|
const turn = (cell) => {
|
|
let { x, y } = cell;
|
|
for (let i = 0; i < quarterTurns; i += 1) [x, y] = [-y, x];
|
|
return { x, y };
|
|
};
|
|
for (const raw of ordered) {
|
|
if (out.length >= count) break;
|
|
const cell = turn(raw);
|
|
const jx = (historyHash01(`${seed}|${globalIndex}|x`) - 0.5) * spacing * 0.10;
|
|
const jy = (historyHash01(`${seed}|${globalIndex}|y`) - 0.5) * spacing * 0.10;
|
|
out.push({
|
|
x: cell.x * spacing + jx,
|
|
y: cell.y * spacing + jy,
|
|
shell,
|
|
});
|
|
globalIndex += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function historyIdealShell(count) {
|
|
const n = Math.max(0, Math.trunc(Number(count) || 0));
|
|
if (!n) return 0;
|
|
let shell = 1;
|
|
while (4 * shell * (shell + 1) < n) shell += 1;
|
|
return shell;
|
|
}
|
|
|
|
function relationEdge(parentId, node) {
|
|
return {
|
|
id: normKey(parentId),
|
|
relationType: String(node?.relationType || 'contact'),
|
|
strength: Math.max(0, Math.min(1, Number(node?.strength) || 0.5)),
|
|
};
|
|
}
|
|
|
|
function stripSecondLevel(model) {
|
|
const nodes = Array.isArray(model?.nodes) ? model.nodes : [];
|
|
const keep = new Set(nodes.filter((node) => (Number(node?.tier) || 1) < 2).map((node) => normKey(node?.id)));
|
|
return {
|
|
...model,
|
|
nodes: nodes
|
|
.filter((node) => keep.has(normKey(node?.id)))
|
|
.map((node) => ({
|
|
...node,
|
|
edgeParents: (Array.isArray(node?.edgeParents) ? node.edgeParents : [])
|
|
.filter((edge) => keep.has(normKey(edge?.id))),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function mapWithConcurrency(items, concurrency, worker) {
|
|
const list = Array.isArray(items) ? items : [];
|
|
const out = new Array(list.length);
|
|
let cursor = 0;
|
|
const runners = new Array(Math.min(Math.max(1, concurrency), list.length)).fill(0).map(async () => {
|
|
while (cursor < list.length) {
|
|
const index = cursor++;
|
|
try { out[index] = await worker(list[index], index); }
|
|
catch (error) { out[index] = { error }; }
|
|
}
|
|
});
|
|
await Promise.all(runners);
|
|
return out;
|
|
}
|
|
|
|
async function buildSecondLevelEngineModel(baseModel, getGraph) {
|
|
const focusKey = normKey(baseModel?.focusId);
|
|
const baseNodes = Array.isArray(baseModel?.nodes) ? baseModel.nodes : [];
|
|
const byKey = new Map();
|
|
|
|
baseNodes.forEach((src) => {
|
|
const key = normKey(src?.id);
|
|
if (!key) return;
|
|
const isFocus = key === focusKey;
|
|
const normalized = {
|
|
...src,
|
|
id: key,
|
|
login: src?.login || src?.id || key,
|
|
tier: 1,
|
|
parentId: isFocus ? '' : focusKey,
|
|
edgeParents: isFocus ? [] : [relationEdge(focusKey, src)],
|
|
};
|
|
byKey.set(key, normalized);
|
|
});
|
|
|
|
const directFriends = [...byKey.values()].filter((node) => (
|
|
normKey(node.id) !== focusKey
|
|
&& (node.relationType === 'friend' || node.relationType === 'close_friend')
|
|
));
|
|
|
|
const fetched = await mapWithConcurrency(directFriends, 4, async (parent) => {
|
|
const graph = await getGraph(parent.login || parent.id);
|
|
const graphModel = buildGraphModel(graph, parent.login || parent.id);
|
|
return { parent, model: engineModelFromGraphModel(graphModel) };
|
|
});
|
|
|
|
fetched.forEach((row) => {
|
|
if (!row || row.error || !row.model) return;
|
|
const parentKey = normKey(row.parent?.id);
|
|
const childNodes = Array.isArray(row.model.nodes) ? row.model.nodes : [];
|
|
childNodes.forEach((child) => {
|
|
const childKey = normKey(child?.id);
|
|
if (!childKey || childKey === parentKey || childKey === focusKey) return;
|
|
if (child.relationType !== 'friend' && child.relationType !== 'close_friend') return;
|
|
const edge = relationEdge(parentKey, child);
|
|
const existing = byKey.get(childKey);
|
|
if (existing) {
|
|
const refs = Array.isArray(existing.edgeParents) ? [...existing.edgeParents] : [];
|
|
if (!refs.some((ref) => normKey(ref?.id) === parentKey)) refs.push(edge);
|
|
byKey.set(childKey, { ...existing, edgeParents: refs });
|
|
return;
|
|
}
|
|
byKey.set(childKey, {
|
|
...child,
|
|
id: childKey,
|
|
login: child?.login || child?.id || childKey,
|
|
tier: 2,
|
|
parentId: parentKey,
|
|
alwaysVisible: true,
|
|
edgeParents: [edge],
|
|
});
|
|
});
|
|
});
|
|
|
|
return { ...baseModel, nodes: [...byKey.values()] };
|
|
}
|
|
|
|
function buildHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVIOUS) {
|
|
const keepPrevious = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Math.trunc(Number(historyDepth) || 0)));
|
|
const maxSnapshots = keepPrevious + 1;
|
|
const snapshots = (Array.isArray(history) ? history : []).slice(-maxSnapshots);
|
|
const latest = snapshots[snapshots.length - 1];
|
|
if (!latest?.engineModel) return latest?.engineModel || { focusId: '', nodes: [] };
|
|
|
|
const centerOrder = new Map();
|
|
const nodeMap = new Map();
|
|
const latestOwner = new Map();
|
|
const edgeMap = new Map();
|
|
|
|
snapshots.forEach((snap, snapIndex) => {
|
|
const centerKey = normKey(snap?.centerLogin);
|
|
if (!centerKey) return;
|
|
centerOrder.set(centerKey, snapIndex);
|
|
const modelNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
|
modelNodes.forEach((rawNode) => {
|
|
const key = normKey(rawNode?.id);
|
|
if (!key) return;
|
|
const node = {
|
|
...nodeMap.get(key),
|
|
...rawNode,
|
|
id: key,
|
|
login: rawNode?.login || rawNode?.id || key,
|
|
};
|
|
if (key === centerKey) {
|
|
node.tier = 1;
|
|
node.keepVisible = true;
|
|
node.isHistoryCenter = true;
|
|
}
|
|
nodeMap.set(key, node);
|
|
latestOwner.set(key, snapIndex);
|
|
|
|
if (key === centerKey) return;
|
|
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
|
|
if (!parents.length) parents = [relationEdge(rawNode?.parentId || centerKey, rawNode)];
|
|
parents.forEach((ref) => {
|
|
const parentKey = normKey(ref?.id || centerKey);
|
|
if (!parentKey || parentKey === key) return;
|
|
const a = parentKey < key ? parentKey : key;
|
|
const b = parentKey < key ? key : parentKey;
|
|
edgeMap.set(`${a}|${b}`, {
|
|
parent: parentKey,
|
|
child: key,
|
|
relationType: String(ref?.relationType || rawNode?.relationType || 'contact'),
|
|
strength: Math.max(0, Math.min(1, Number(ref?.strength) || Number(rawNode?.strength) || 0.5)),
|
|
snapIndex,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
const occupied = [];
|
|
const placed = new Map();
|
|
const clearanceForNode = (node) => (Number(node?.tier) || 1) >= 2 ? HISTORY_TIER2_CLEARANCE : HISTORY_NODE_CLEARANCE;
|
|
const collides = (x, y, clearance, rows = occupied, ignoreKey = '') => rows.some((row) => {
|
|
if (ignoreKey && row.key === ignoreKey) return false;
|
|
return Math.hypot(x - row.x, y - row.y) < Math.max(clearance, row.clearance || 0);
|
|
});
|
|
|
|
function ownedNodesForSnapshot(snapIndex) {
|
|
const centerKey = normKey(snapshots[snapIndex]?.centerLogin);
|
|
return [...nodeMap.entries()]
|
|
.filter(([key]) => latestOwner.get(key) === snapIndex && key !== centerKey && !centerOrder.has(key))
|
|
.map(([key, node]) => ({ key, node }));
|
|
}
|
|
|
|
function tryPlaceCluster(snapIndex, center, baseOccupied, commit = false) {
|
|
const snap = snapshots[snapIndex];
|
|
const centerKey = normKey(snap?.centerLogin);
|
|
const localPlaced = new Map([[centerKey, { ...center }]]);
|
|
const rows = baseOccupied.map((row) => ({ ...row }));
|
|
if (collides(center.x, center.y, HISTORY_CENTER_CLEARANCE, rows, centerKey)) {
|
|
return { ok: false, maxDirectShell: Infinity, idealDirectShell: 0, placed: localPlaced, rows };
|
|
}
|
|
rows.push({ key: centerKey, x: center.x, y: center.y, clearance: HISTORY_CENTER_CLEARANCE });
|
|
|
|
const owned = ownedNodesForSnapshot(snapIndex);
|
|
const direct = owned.filter(({ node }) => (Number(node?.tier) || 1) < 2);
|
|
const deep = owned.filter(({ node }) => (Number(node?.tier) || 1) >= 2);
|
|
const directSlots = historyCompactSlots(Math.max(direct.length + 24, 48), `${centerKey}|direct`, HISTORY_DIRECT_SPACING);
|
|
let maxDirectShell = 0;
|
|
|
|
direct.forEach(({ key, node }) => {
|
|
let chosen = null;
|
|
const clearance = clearanceForNode(node);
|
|
for (const slot of directSlots) {
|
|
const x = center.x + slot.x;
|
|
const y = center.y + slot.y;
|
|
if (!collides(x, y, clearance, rows, key)) {
|
|
chosen = { x, y, shell: slot.shell };
|
|
break;
|
|
}
|
|
}
|
|
if (!chosen) {
|
|
const angle = historyHash01(`${centerKey}|${key}|fallback`) * Math.PI * 2;
|
|
const radius = HISTORY_DIRECT_SPACING * (HISTORY_LAYOUT_MAX_SHELL + 1);
|
|
chosen = { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius, shell: HISTORY_LAYOUT_MAX_SHELL + 1 };
|
|
}
|
|
maxDirectShell = Math.max(maxDirectShell, chosen.shell || 0);
|
|
localPlaced.set(key, chosen);
|
|
rows.push({ key, x: chosen.x, y: chosen.y, clearance });
|
|
});
|
|
|
|
const deepByParent = new Map();
|
|
deep.forEach((row) => {
|
|
const parentKey = normKey(row.node?.parentId || row.node?.edgeParents?.[0]?.id || centerKey);
|
|
const list = deepByParent.get(parentKey) || [];
|
|
list.push(row);
|
|
deepByParent.set(parentKey, list);
|
|
});
|
|
for (const [parentKey, children] of deepByParent.entries()) {
|
|
const parentPos = localPlaced.get(parentKey) || placed.get(parentKey) || center;
|
|
const slots = historyCompactSlots(Math.max(children.length + 16, 32), `${centerKey}|${parentKey}|deep`, HISTORY_TIER2_SPACING, 6);
|
|
children.forEach(({ key, node }) => {
|
|
let chosen = null;
|
|
const clearance = clearanceForNode(node);
|
|
for (const slot of slots) {
|
|
const x = parentPos.x + slot.x;
|
|
const y = parentPos.y + slot.y;
|
|
if (!collides(x, y, clearance, rows, key)) {
|
|
chosen = { x, y, shell: slot.shell };
|
|
break;
|
|
}
|
|
}
|
|
if (!chosen) {
|
|
const angle = historyHash01(`${parentKey}|${key}|deep-fallback`) * Math.PI * 2;
|
|
const radius = HISTORY_TIER2_SPACING * 7;
|
|
chosen = { x: parentPos.x + Math.cos(angle) * radius, y: parentPos.y + Math.sin(angle) * radius, shell: 7 };
|
|
}
|
|
localPlaced.set(key, chosen);
|
|
rows.push({ key, x: chosen.x, y: chosen.y, clearance });
|
|
});
|
|
}
|
|
|
|
const idealDirectShell = historyIdealShell(direct.length);
|
|
const ok = maxDirectShell <= Math.max(1, idealDirectShell + 1);
|
|
if (commit) {
|
|
localPlaced.forEach((pos, key) => placed.set(key, { x: pos.x, y: pos.y }));
|
|
occupied.splice(0, occupied.length, ...rows);
|
|
}
|
|
return { ok, maxDirectShell, idealDirectShell, placed: localPlaced, rows };
|
|
}
|
|
|
|
// Текущий центр всегда в (0,0). Сначала укладываем его актуальный круг максимально компактно.
|
|
const latestIndex = snapshots.length - 1;
|
|
tryPlaceCluster(latestIndex, { x: 0, y: 0 }, occupied, true);
|
|
|
|
// Предыдущие центры добавляем назад по истории. Мы НЕ держим большой фиксированный минимум:
|
|
// сначала пробуем близкую посадку в нескольких направлениях вокруг исходного перехода и выбираем
|
|
// наиболее компактную общую форму карты. Только если ни один вариант не помещается — увеличиваем gap.
|
|
// Так 3–4 небольших круга складываются скорее в облако/квадрат, а не в длинную цепочку.
|
|
const angleOffsets = [0, Math.PI / 8, -Math.PI / 8, Math.PI / 4, -Math.PI / 4, Math.PI / 2, -Math.PI / 2];
|
|
const compactnessScore = (rows, desiredAngleOffset = 0) => {
|
|
if (!rows.length) return 0;
|
|
let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity;
|
|
rows.forEach((row) => {
|
|
const c = Number(row.clearance) || HISTORY_NODE_CLEARANCE;
|
|
minX = Math.min(minX, row.x - c * 0.5);
|
|
maxX = Math.max(maxX, row.x + c * 0.5);
|
|
minY = Math.min(minY, row.y - c * 0.5);
|
|
maxY = Math.max(maxY, row.y + c * 0.5);
|
|
});
|
|
const width = Math.max(1, maxX - minX);
|
|
const height = Math.max(1, maxY - minY);
|
|
const area = width * height;
|
|
const aspectPenalty = Math.abs(Math.log(width / height));
|
|
// Площадь важнее всего; небольшой штраф за вытянутый прямоугольник и сильный уход
|
|
// от направления фактического клика сохраняет ощущение истории, но не ценой огромных пустот.
|
|
return area * (1 + aspectPenalty * 0.22) + Math.abs(desiredAngleOffset) * 2600;
|
|
};
|
|
|
|
for (let snapIndex = latestIndex - 1; snapIndex >= 0; snapIndex -= 1) {
|
|
const newer = snapshots[snapIndex + 1];
|
|
const newerKey = normKey(newer?.centerLogin);
|
|
const newerPos = placed.get(newerKey) || { x: 0, y: 0 };
|
|
const baseAngle = Number.isFinite(Number(newer?.transitionAngle)) ? Number(newer.transitionAngle) : 0;
|
|
let gap = HISTORY_CENTER_GAP_START;
|
|
let best = null;
|
|
|
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
const candidates = [];
|
|
angleOffsets.forEach((offset) => {
|
|
const angle = baseAngle + offset;
|
|
const candidate = {
|
|
x: newerPos.x - Math.cos(angle) * gap,
|
|
y: newerPos.y - Math.sin(angle) * gap,
|
|
};
|
|
const trial = tryPlaceCluster(snapIndex, candidate, occupied, false);
|
|
if (!trial.ok) return;
|
|
candidates.push({
|
|
candidate,
|
|
offset,
|
|
score: compactnessScore(trial.rows, offset),
|
|
});
|
|
});
|
|
if (candidates.length) {
|
|
candidates.sort((a, b) => a.score - b.score);
|
|
best = candidates[0];
|
|
break;
|
|
}
|
|
gap += HISTORY_CENTER_GAP_STEP;
|
|
}
|
|
|
|
const fallbackAngle = baseAngle + (snapIndex % 2 === 0 ? Math.PI / 8 : -Math.PI / 8);
|
|
const candidate = best?.candidate || {
|
|
x: newerPos.x - Math.cos(fallbackAngle) * gap,
|
|
y: newerPos.y - Math.sin(fallbackAngle) * gap,
|
|
};
|
|
tryPlaceCluster(snapIndex, candidate, occupied, true);
|
|
}
|
|
|
|
// На случай центра без периферии, который не попал в occupied из-за повреждённого snapshot.
|
|
snapshots.forEach((snap) => {
|
|
const key = normKey(snap?.centerLogin);
|
|
if (!placed.has(key)) placed.set(key, { x: 0, y: 0 });
|
|
});
|
|
|
|
const edgeParentsByChild = new Map();
|
|
const addEdgeParent = (childKey, parentKey, edge) => {
|
|
if (!childKey || !parentKey || childKey === parentKey) return;
|
|
const list = edgeParentsByChild.get(childKey) || [];
|
|
if (!list.some((row) => normKey(row?.id) === parentKey)) {
|
|
list.push({ id: parentKey, relationType: edge.relationType, strength: edge.strength });
|
|
}
|
|
edgeParentsByChild.set(childKey, list);
|
|
};
|
|
for (const edge of edgeMap.values()) {
|
|
if (!nodeMap.has(edge.parent) || !nodeMap.has(edge.child)) continue;
|
|
addEdgeParent(edge.child, edge.parent, edge);
|
|
}
|
|
|
|
const nodes = [...nodeMap.entries()].map(([key, node]) => {
|
|
const pos = placed.get(key) || { x: 0, y: 0 };
|
|
const isCenter = centerOrder.has(key);
|
|
return {
|
|
...node,
|
|
id: key,
|
|
login: node.login || node.id || key,
|
|
tier: isCenter ? 1 : Math.max(1, Number(node?.tier) || 1),
|
|
layoutX: pos.x,
|
|
layoutY: pos.y,
|
|
fixedLayout: true,
|
|
keepVisible: isCenter || Boolean(node.keepVisible),
|
|
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
|
|
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 historyDepth = Math.max(0, Math.min(HISTORY_MAX_PREVIOUS, Number(persistedHistoryDepth) || 0));
|
|
let x2Enabled = Boolean(persistedX2Enabled);
|
|
let engine = null;
|
|
let loadSeq = 0;
|
|
const graphCache = new Map();
|
|
let historyChip = null;
|
|
let x2Chip = null;
|
|
|
|
// Независимые фильтры карты. Оба выключены = показываем всё.
|
|
// Их можно сочетать: «Близкие» + «Сияющие» оставляет только сияющих близких друзей.
|
|
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];
|
|
persistedHistoryDepth = historyDepth;
|
|
persistedX2Enabled = x2Enabled;
|
|
}
|
|
|
|
function rebuildEngineFromHistory() {
|
|
const engineModel = buildHistoryEngineModel(graphHistory, historyDepth);
|
|
ensureEngine(engineModel);
|
|
if (engine && activeFilters.size > 0) engine.setFilter(currentFilterPredicate);
|
|
}
|
|
|
|
function updateHistoryChip() {
|
|
if (!(historyChip instanceof HTMLButtonElement)) return;
|
|
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
|
|
historyChip.classList.toggle('is-active', historyDepth > 0);
|
|
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
|
|
historyChip.title = historyDepth > 0
|
|
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
|
|
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.';
|
|
}
|
|
|
|
function cycleHistoryDepth() {
|
|
historyDepth = historyDepth >= HISTORY_MAX_PREVIOUS ? 0 : historyDepth + 1;
|
|
const maxSnapshots = historyDepth + 1;
|
|
graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1);
|
|
centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : [];
|
|
updateHistoryChip();
|
|
rebuildEngineFromHistory();
|
|
persistHistory();
|
|
}
|
|
|
|
function updateX2Chip() {
|
|
if (!(x2Chip instanceof HTMLButtonElement)) return;
|
|
x2Chip.classList.toggle('is-active', x2Enabled);
|
|
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
|
|
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
|
|
}
|
|
|
|
async function toggleX2() {
|
|
x2Enabled = !x2Enabled;
|
|
updateX2Chip();
|
|
persistedX2Enabled = x2Enabled;
|
|
await load(centerLogin, { pushHistory: false });
|
|
}
|
|
|
|
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);
|
|
graphCache.set(normKey(targetCenter), graph);
|
|
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);
|
|
let snapshotModel = engineModelFromGraphModel(graphModel);
|
|
if (x2Enabled) {
|
|
snapshotModel = await buildSecondLevelEngineModel(snapshotModel, async (login) => {
|
|
const key = normKey(login);
|
|
if (graphCache.has(key)) return graphCache.get(key);
|
|
const childGraph = await authService.getUserConnectionsGraph(login);
|
|
graphCache.set(key, childGraph);
|
|
return childGraph;
|
|
});
|
|
if (requestId !== loadSeq) return;
|
|
}
|
|
|
|
const snapshot = {
|
|
centerLogin: targetCenter,
|
|
engineModel: snapshotModel,
|
|
transitionAngle: Number.isFinite(Number(transitionAngle)) ? Number(transitionAngle) : 0,
|
|
};
|
|
|
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
|
// X2 относится только к ТЕКУЩЕМУ центру. Как только центр уходит в историю, оставляем его
|
|
// первый уровень — иначе четыре исторических слоя с друзьями друзей быстро превратятся в кашу.
|
|
graphHistory = graphHistory.map((row) => ({ ...row, engineModel: stripSecondLevel(row.engineModel) }));
|
|
graphHistory = graphHistory.filter((row) => normKey(row?.centerLogin) !== normKey(targetCenter));
|
|
graphHistory.push(snapshot);
|
|
const maxSnapshots = historyDepth + 1;
|
|
graphHistory = historyDepth > 0 ? graphHistory.slice(-maxSnapshots) : graphHistory.slice(-1);
|
|
centerHistory = historyDepth > 0 ? centerHistory.slice(-historyDepth) : [];
|
|
} 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];
|
|
}
|
|
}
|
|
|
|
rebuildEngineFromHistory();
|
|
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);
|
|
});
|
|
|
|
historyChip = document.createElement('button');
|
|
historyChip.type = 'button';
|
|
historyChip.className = 'fg-filter-chip fg-history-chip';
|
|
historyChip.addEventListener('click', cycleHistoryDepth);
|
|
filterBar.append(historyChip);
|
|
updateHistoryChip();
|
|
|
|
x2Chip = document.createElement('button');
|
|
x2Chip.type = 'button';
|
|
x2Chip.className = 'fg-filter-chip fg-x2-chip';
|
|
x2Chip.textContent = 'X2';
|
|
x2Chip.addEventListener('click', () => { void toggleX2(); });
|
|
filterBar.append(x2Chip);
|
|
updateX2Chip();
|
|
|
|
chrome?.setTopbar(header);
|
|
stage.append(board, filterBar);
|
|
screen.append(stage);
|
|
return screen;
|
|
}
|