SHiNE-server/shine-UI/js/pages/network/lab.js
Pixel 519bce6b78 Связи (pixel-aquarium, 10.06): партия 3 (лаборатория) — общие связи + доступность
Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем).

- Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок
  + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана.
- Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня)
  для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label).

Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS.
Бамп client.version → 1.2.150.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:53:49 +03:00

342 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Лабораторный режим карты связей (network-view/lab).
//
// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование,
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально).
//
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
// Это чисто визуальный лабораторный эксперимент на мок-данных.
import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js';
import { createForceGraph, buildModelFromTz } from './force-graph.js';
import { openNodeMenu } from './node-menu.js';
const START_LOGIN = 'ivan';
// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам;
// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие».
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'];
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
function seed01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
function helpText() {
return [
'Лаборатория карты связей (мок-данные, без сервера).',
'• Тащите по экрану — карта свободно перемещается (pan).',
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
}
// Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей).
function graphForLogin(login) {
const key = String(login || '').trim().toLowerCase();
if (networkGraphUsers[key]) return networkGraphUsers[key];
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
}
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
function synthTier1(focusId) {
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
const out = [];
for (let i = 0; i < k; i += 1) {
const id = `${focusId}__t1_${i}`;
const s = seed01(id);
out.push({
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
avatar: null, photo: null,
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
connectionStrength: 0.5 + s * 0.4,
status: s > 0.78 ? 'shining' : '',
hasOwnConnections: true, tier: 1,
});
}
return out;
}
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
function addDeepLevels(model) {
const focusId = model.focusId;
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
const extra = [];
tier1.forEach((p) => {
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
const others = tier1.filter((o) => String(o.id) !== String(p.id));
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
if (i === 0 && common) {
extra.push({
id: id2, login: id2, name: common.name || common.login,
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
});
continue;
}
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
extra.push({
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
avatar: null, photo: face3, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
}
});
return { focusId, nodes: [...model.nodes, ...extra] };
}
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
function buildLabModel(login, deep, fromLogin) {
const tz = graphForLogin(login);
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
else tz.connections = [];
}
const base = buildModelFromTz(tz);
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
const fid = String(fromLogin);
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
if (found) {
found.track = true; // уже среди связей — просто подсветим трек
} else {
const f = graphForLogin(fromLogin).focusUser || {};
base.nodes.push({
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
photo: f.photo || null, relationType: 'friend', strength: 0.97,
shining: false, tier: 1, track: true,
});
}
}
return deep ? addDeepLevels(base) : base;
}
export function renderNetworkLab({ navigate }) {
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 fg-stage';
const header = renderHeader({
title: 'Связи · лаборатория',
leftAction: { label: '←', onClick: () => navigate('network-view') },
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
});
header.classList.add('network-header-overlay');
let centerLogin = START_LOGIN;
let deepMode = false;
// Состояние активного слоя (как в network-view): фокус всегда виден.
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);
});
graph.setFilter(FILTERS[key].pred);
}
stage.append(header);
screen.append(stage);
const model = buildLabModel(centerLogin, deepMode);
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
stage,
model,
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
if (deepMode) {
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
graph.diveTo(node);
return;
}
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
const from = centerLogin;
centerLogin = node.login || node.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
},
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
// Изменение пути погружения → перерисовываем хлебные крошки (Иван Нина Ада).
onDiveChange: (path) => renderBreadcrumb(path),
onCenterTap: (node) => {
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
if (deepMode) { graph.collapseAll(); return; }
window.alert(`Профиль: ${node.name || node.login || node.id}`);
},
onNodeLongPress: (node, point) => openNodeMenu({
login: node.name || node.login || node.id,
relationType: node.relationType,
point,
actions: [
{ label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) },
{ label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) },
],
}),
});
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» 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);
});
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
const deepChip = document.createElement('button');
deepChip.type = 'button';
deepChip.className = 'fg-filter-chip fg-deep-chip';
deepChip.textContent = '🌌 Вселенная';
deepChip.addEventListener('click', () => {
deepMode = !deepMode;
deepChip.classList.toggle('is-active', deepMode);
graph.setModel(buildLabModel(centerLogin, deepMode));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
});
filterBar.append(deepChip);
stage.append(filterBar);
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
const searchWrap = document.createElement('div');
searchWrap.className = 'fg-search';
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
const searchIco = document.createElement('span');
searchIco.className = 'fg-search-ico';
searchIco.textContent = '🔍';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Найти человека…';
searchInput.setAttribute('aria-label', 'Поиск по имени');
function doSearch() {
const hit = graph.findNode(searchInput.value);
if (!hit) return;
if (deepMode) {
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
} else {
const from = centerLogin;
centerLogin = hit.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
}
searchInput.blur();
}
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
searchWrap.append(searchIco, searchInput);
stage.append(searchWrap);
// --- Хлебные крошки: стек погружений (Иван Нина Ада); клик по крошке — навигация назад ---
breadcrumbEl = document.createElement('div');
breadcrumbEl.className = 'fg-breadcrumb';
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
stage.append(breadcrumbEl);
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
function renderBreadcrumb(path) {
if (!breadcrumbEl) return;
breadcrumbEl.innerHTML = '';
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
breadcrumbEl.classList.toggle('is-open', open);
if (!open) return;
path.forEach((p, i) => {
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = ''; breadcrumbEl.append(sep); }
const c = document.createElement('button');
c.type = 'button';
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
c.textContent = p.name;
if (i < path.length - 1) {
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
}
breadcrumbEl.append(c);
});
}
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
window.__fg = graph;
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
}
screen.cleanup = () => {
graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock');
};
return screen;
}