- Линии: тонкие дуги Безье (градиент неон-центр → цвет роли); связь к «сияющему» монолитно светится статичной тенью drop-shadow (без бегущих импульсов). - Прорастание новых линий из центра: stroke-dasharray/dashoffset синхронно с разлётом узла (кончик трекает аватарку); старые линии исчезают мгновенно. - Ghost-слой: только аватарки (без линий), 1000мс — нет висящих «ошмётков». - CSS-bloom разлёта на компоновщике (устойчив к троттлингу rAF; завершение по таймеру). - Сияющие узлы: мягкая медленная пульсация 3.6с (многослойная box-shadow + SVG-ореол); тестовые фото-аватарки. - Фильтры слоёв в лаборатории + фикс перехвата click сценой (stopPropagation на чипах); фейд скрываемых на месте (opacity 0 + scale 0.8, 300мс), фиксация без физики (ноль тряски). - Бамп client.version → 1.2.137; обновлена документация фичи. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
584 lines
22 KiB
JavaScript
584 lines
22 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
||
import { authService, state } from '../state.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';
|
||
|
||
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,
|
||
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();
|
||
applyRelativeGender(map, graph?.parents);
|
||
applyRelativeGender(map, graph?.children);
|
||
applyRelativeGender(map, graph?.siblings);
|
||
applyRelativeGender(map, graph?.spouses);
|
||
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 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 outKnown = toSet(graph?.outKnownPersons);
|
||
const inKnown = toSet(graph?.inKnownPersons);
|
||
|
||
const relativesGender = getRelativeGenderMap(graph);
|
||
const allMarks = getMarkByLogin(graph?.allUsers);
|
||
|
||
const allLogins = uniqueLogins([
|
||
...(graph?.outFriends || []),
|
||
...(graph?.inFriends || []),
|
||
...(graph?.outParents || []),
|
||
...(graph?.inParents || []),
|
||
...(graph?.outChildren || []),
|
||
...(graph?.inChildren || []),
|
||
...(graph?.outSiblings || []),
|
||
...(graph?.inSiblings || []),
|
||
...(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) => {
|
||
const parentOut = hasLogin(outParents, targetLogin);
|
||
const parentIn = hasLogin(inChildren, targetLogin);
|
||
const childOut = hasLogin(outChildren, targetLogin);
|
||
const childIn = hasLogin(inParents, targetLogin);
|
||
const siblingOut = hasLogin(outSiblings, targetLogin);
|
||
const siblingIn = hasLogin(inSiblings, targetLogin);
|
||
const spouseOut = hasLogin(outSpouses, targetLogin);
|
||
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 = '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;
|
||
if (role === 'parent') {
|
||
forward = parentOut;
|
||
backward = parentIn;
|
||
} else if (role === 'child') {
|
||
forward = childOut;
|
||
backward = childIn;
|
||
} else if (role === 'spouse') {
|
||
forward = spouseOut;
|
||
backward = spouseIn;
|
||
} 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 === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
|
||
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 = [];
|
||
|
||
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) {
|
||
persistedCenterLogin = '';
|
||
persistedCenterHistory = [];
|
||
}
|
||
|
||
const screen = document.createElement('section');
|
||
screen.className = 'network-screen';
|
||
const appScreenEl = document.getElementById('app-screen');
|
||
appScreenEl?.classList.add('network-scroll-lock');
|
||
|
||
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 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 '';
|
||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||
return makeProfileRoute(cleanLogin);
|
||
}
|
||
|
||
function helpText() {
|
||
return [
|
||
'Обозначения на экране связей:',
|
||
'• Синие линии — близкие друзья.',
|
||
'• Оранжевые линии — родственники.',
|
||
'• Стрелка на линии — односторонняя связь.',
|
||
'• Если стрелки нет, связь взаимная.',
|
||
].join('\n');
|
||
}
|
||
|
||
function persistHistory() {
|
||
persistedCenterLogin = centerLogin;
|
||
persistedCenterHistory = [...centerHistory];
|
||
}
|
||
|
||
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 setBackButtonState(backBtn) {
|
||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||
backBtn.disabled = centerHistory.length === 0;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
|
||
function showNodeSheet(node) {
|
||
if (!sheetEl) {
|
||
sheetEl = document.createElement('div');
|
||
sheetEl.className = 'fg-sheet';
|
||
stage.append(sheetEl);
|
||
}
|
||
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)}`);
|
||
};
|
||
}
|
||
|
||
function hideNodeSheet() {
|
||
if (sheetEl) sheetEl.classList.remove('is-open');
|
||
}
|
||
|
||
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);
|
||
},
|
||
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
||
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)}`) },
|
||
],
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
async function load(nextCenterLogin = '', { pushHistory = false } = {}) {
|
||
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 engineModel = engineModelFromGraphModel(graphModel);
|
||
ensureEngine(engineModel);
|
||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||
|
||
persistHistory();
|
||
setBackButtonState(backBtnEl);
|
||
} catch (error) {
|
||
if (requestId !== loadSeq) return;
|
||
window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
|
||
}
|
||
}
|
||
|
||
const header = renderHeader({
|
||
title: 'Связи',
|
||
leftAction: {
|
||
label: '←',
|
||
onClick: () => {
|
||
if (!centerHistory.length) return;
|
||
const prev = centerHistory.pop();
|
||
if (!prev) {
|
||
setBackButtonState(backBtnEl);
|
||
return;
|
||
}
|
||
void load(prev, { pushHistory: false });
|
||
},
|
||
},
|
||
rightActions: [
|
||
{ label: 'Найти', onClick: openSearchModal },
|
||
{ label: '?', onClick: () => window.alert(helpText()) },
|
||
],
|
||
});
|
||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||
setBackButtonState(backBtnEl);
|
||
|
||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||
screen.cleanup = () => {
|
||
if (engine) engine.destroy();
|
||
engine = null;
|
||
appScreenEl?.classList.remove('network-scroll-lock');
|
||
};
|
||
|
||
if (routeLogin) {
|
||
centerLogin = routeLogin;
|
||
centerHistory = [];
|
||
persistHistory();
|
||
void load(centerLogin, { pushHistory: false });
|
||
} else if (keepHistory && centerLogin) {
|
||
void load(centerLogin, { pushHistory: false });
|
||
} else {
|
||
centerLogin = normalizeLogin(state.session.login || '');
|
||
centerHistory = [];
|
||
persistHistory();
|
||
if (centerLogin) {
|
||
void load(centerLogin, { pushHistory: false });
|
||
} else {
|
||
window.setTimeout(() => openSearchModal(), 0);
|
||
}
|
||
}
|
||
setBackButtonState(backBtnEl);
|
||
|
||
// Панель фильтров слоёв (оверлей под шапкой)
|
||
const filterBar = document.createElement('div');
|
||
filterBar.className = 'fg-filter-bar';
|
||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает 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${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, filterBar);
|
||
screen.append(stage);
|
||
return screen;
|
||
}
|