прод: убрать граф-лабу (network/lab.js, selftest.js, граф-мок в mock-data)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-19 20:31:09 +03:00
parent de269fd828
commit 465792b2ab
4 changed files with 8 additions and 635 deletions

View File

@ -49,21 +49,15 @@ export const deviceSessions = [
// unreadCount: number; preview: string // unreadCount: number; preview: string
// toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать // toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать
// (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо) // (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо)
// ЛС-демо (мок). Поля СЕМАНТИЧЕСКИЕ (без хранения цвета) — визуал решает dm-visual-resolver.js. // ЛС — мок-плейсхолдер (на проде заменяется реальными relations/chats). Поля СЕМАНТИЧЕСКИЕ
// Набор покрывает ровно матрицу состояний M01M06 из спеки (по одному кейсу на строку). // (без хранения цвета) — визуал решает dm-visual-resolver.js. Аватары — через профиль (инициалы, пока нет фото).
export const directMessages = [ export const directMessages = [
// M01 — обычный контакт, подтверждён: violet-обод, справа золотой shield «Подтверждён», без unread. { id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
{ id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0, photo: '/assets/demo-avatars/u1.jpg' }, { id: 'u2', login: 'ilya', name: 'Илья П.', initials: 'ИП', preview: 'Спасибо, уже проверяю!', lastMessage: 'Спасибо, уже проверяю!', time: '14:31', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: true, unreadCount: 2, connectedVia: [{ login: 'pavel', name: 'Павел С.' }] },
// M02 — обычная активная связь: violet-обод, изумрудная капсула «Связь», отдельная violet-сфера «2». { id: 'u3', login: 'elena', name: 'Елена Д.', initials: 'ЕД', preview: 'Тестовый стенд снова доступен.', lastMessage: 'Тестовый стенд снова доступен.', time: '13:02', relationType: 'contact', relationRole: null, isShining: true, isConfirmed: false, hasActiveLink: true, unreadCount: 5, connectedVia: [{ login: 'pavel', name: 'Павел С.' }, { login: 'marina', name: 'Марина К.' }] },
{ id: 'u2', login: 'ilya', name: 'Илья П.', initials: 'ИП', preview: 'Спасибо, уже проверяю!', lastMessage: 'Спасибо, уже проверяю!', time: '14:31', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: true, unreadCount: 2, photo: '/assets/demo-avatars/u2.jpg', connectedVia: [{ login: 'pavel', name: 'Павел С.', photo: '/assets/demo-avatars/u6.jpg' }] }, { id: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0 },
// M03 — сияющий с активной связью: мини-сфера языка «Связи», изумруд «Связь», violet-сфера «5». { id: 'u6', login: 'pavel', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
{ id: 'u3', login: 'elena', name: 'Елена Д.', initials: 'ЕД', preview: 'Тестовый стенд снова доступен.', lastMessage: 'Тестовый стенд снова доступен.', time: '13:02', relationType: 'contact', relationRole: null, isShining: true, isConfirmed: false, hasActiveLink: true, unreadCount: 5, photo: '/assets/demo-avatars/u3.jpg', connectedVia: [{ login: 'pavel', name: 'Павел С.', photo: '/assets/demo-avatars/u6.jpg' }, { login: 'marina', name: 'Марина К.', photo: '/assets/demo-avatars/u1.jpg' }] }, { id: 'u7', login: 'anya', name: 'Аня В.', initials: 'АВ', preview: 'Семейный чат: жду в 19:00.', lastMessage: 'Семейный чат: жду в 19:00.', time: 'пн', relationType: 'family', relationRole: 'sibling', isShining: false, isConfirmed: true, hasActiveLink: true, unreadCount: 1, connectedVia: [{ login: 'marina', name: 'Марина К.' }] },
// M04 — обычный контакт без статуса: только violet-обод и спокойный chevron, без unread.
{ id: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0, photo: '/assets/demo-avatars/u4.jpg' },
// M05 — семья с подтверждением: золотой обод + тёплая аура, справа золотой shield «Подтверждён».
{ id: 'u6', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0, photo: '/assets/demo-avatars/u6.jpg' },
// M06 — семья с активной связью: золотой обод (family), справа «Связь» по приоритету link>confirmed, violet-сфера «1».
{ id: 'u7', login: 'anya', name: 'Аня В.', initials: 'АВ', preview: 'Семейный чат: жду в 19:00.', lastMessage: 'Семейный чат: жду в 19:00.', time: 'пн', relationType: 'family', relationRole: 'sibling', isShining: false, isConfirmed: true, hasActiveLink: true, unreadCount: 1, photo: '/assets/demo-avatars/u7.jpg', connectedVia: [{ login: 'marina', name: 'Марина К.', photo: '/assets/demo-avatars/u1.jpg' }] },
]; ];
export const contactDirectory = [ export const contactDirectory = [
@ -263,136 +257,3 @@ export const notifications = {
], ],
}; };
export const networkGraph = {
center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 },
peers: [
{ id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 },
{ id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 },
{ id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 },
{ id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 },
],
};
// Мок интерактивной карты связей в форме ТЗ (focusUser + connections[]).
// Используется лабораторным режимом `network-view/lab` для проверки физики/центрирования.
// relationType: family | friend | business | contact; connectionStrength: 0..1 (сильнее → ближе к центру);
// status: 'shining' даёт эффект свечения; hasOwnConnections — есть ли у узла свои связи (для глубины).
export const networkGraphMock = {
focusUser: { id: 'u_100', login: 'ivan', name: 'Иван', avatar: 'url_to_image', status: 'shining' },
connections: [
{ id: 'u_101', login: 'alisa', name: 'Алиса', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.95, hasOwnConnections: true, status: 'shining' },
{ id: 'u_102', login: 'pavel', name: 'Павел', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.45, hasOwnConnections: false },
{ id: 'u_103', login: 'marina', name: 'Марина', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.8, hasOwnConnections: true },
{ id: 'u_104', login: 'ilya', name: 'Илья', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.6, hasOwnConnections: true },
{ id: 'u_105', login: 'elena', name: 'Елена', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.88, hasOwnConnections: false },
{ id: 'u_106', login: 'nikita', name: 'Никита', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.3, hasOwnConnections: false },
{ id: 'u_107', login: 'oleg', name: 'Олег', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.55, hasOwnConnections: true, status: 'shining' },
{ id: 'u_108', login: 'sveta', name: 'Света', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.7, hasOwnConnections: false },
{ id: 'u_109', login: 'dmitry', name: 'Дмитрий', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.4, hasOwnConnections: true },
{ id: 'u_110', login: 'anna', name: 'Анна', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.92, hasOwnConnections: false },
],
};
// Связанный мульти-пользовательский граф для лаборатории (network-view/lab):
// у каждого пользователя свой набор связей, тап по узлу переключает карту на его сеть.
// Сияющими считаем ivan/alisa/oleg — у них статус подсвечивается и в их карточках у других.
const NETWORK_NAMES = {
ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий',
oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл',
};
const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg', 'marina', 'nina']);
// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории.
// Если сети нет — узлы мягко падают на инициалы (img.onerror).
const NETWORK_PHOTOS = {
ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5',
pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9',
dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52',
nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44',
sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60',
};
function networkConn(login, relationType, connectionStrength) {
return {
id: login,
login,
name: NETWORK_NAMES[login] || login,
avatar: null,
photo: NETWORK_PHOTOS[login] || null,
relationType,
connectionStrength,
hasOwnConnections: true,
status: NETWORK_SHINING.has(login) ? 'shining' : '',
};
}
function networkPerson(login, connections) {
return {
focusUser: {
id: login,
login,
name: NETWORK_NAMES[login] || login,
avatar: null,
photo: NETWORK_PHOTOS[login] || null,
status: NETWORK_SHINING.has(login) ? 'shining' : '',
},
connections,
};
}
export const networkGraphUsers = {
ivan: networkPerson('ivan', [
networkConn('alisa', 'friend', 0.9),
networkConn('pavel', 'friend', 0.7),
networkConn('elena', 'family', 0.95),
networkConn('dmitry', 'family', 0.95),
networkConn('oleg', 'business', 0.5),
networkConn('nina', 'contact', 0.35),
networkConn('kirill', 'friend', 0.6),
]),
alisa: networkPerson('alisa', [
networkConn('ivan', 'friend', 0.9),
networkConn('marina', 'friend', 0.8),
networkConn('sveta', 'contact', 0.4),
networkConn('elena', 'contact', 0.3),
]),
pavel: networkPerson('pavel', [
networkConn('ivan', 'friend', 0.7),
networkConn('oleg', 'business', 0.6),
networkConn('kirill', 'friend', 0.5),
]),
elena: networkPerson('elena', [
networkConn('ivan', 'family', 0.95),
networkConn('dmitry', 'family', 0.9),
networkConn('alisa', 'contact', 0.3),
]),
dmitry: networkPerson('dmitry', [
networkConn('ivan', 'family', 0.95),
networkConn('elena', 'family', 0.9),
networkConn('pavel', 'business', 0.4),
]),
oleg: networkPerson('oleg', [
networkConn('pavel', 'business', 0.6),
networkConn('ivan', 'business', 0.5),
networkConn('nina', 'contact', 0.45),
]),
nina: networkPerson('nina', [
networkConn('ivan', 'contact', 0.35),
networkConn('oleg', 'contact', 0.45),
networkConn('sveta', 'friend', 0.5),
]),
marina: networkPerson('marina', [
networkConn('alisa', 'friend', 0.8),
networkConn('sveta', 'friend', 0.7),
networkConn('kirill', 'contact', 0.4),
]),
sveta: networkPerson('sveta', [
networkConn('marina', 'friend', 0.7),
networkConn('alisa', 'contact', 0.4),
networkConn('nina', 'friend', 0.5),
]),
kirill: networkPerson('kirill', [
networkConn('ivan', 'friend', 0.6),
networkConn('pavel', 'friend', 0.5),
networkConn('marina', 'contact', 0.4),
]),
};

View File

@ -2,7 +2,6 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } 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 { createForceGraph } from './network/force-graph.js';
import { engineModelFromGraphModel } from './network/adapter.js'; import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu, relationLabelRu } from './network/node-menu.js'; import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
@ -217,10 +216,6 @@ let persistedCenterLogin = '';
let persistedCenterHistory = []; let persistedCenterHistory = [];
export function render({ navigate, route }) { 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 keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || ''); const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) { if (!keepHistory) {

View File

@ -1,333 +0,0 @@
// Лабораторный режим карты связей (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: Boolean(common.shining), 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;
// Констелляция (паутина 2-3 уровней) — ПОСТОЯННЫЙ режим по умолчанию (чип «Вселенная» убран).
let deepMode = true;
// Состояние активного слоя (как в 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);
});
// Чип «Вселенная» убран: констелляция — постоянный режим (deepMode=true по умолчанию).
// Семья/Друзья/Сияющие остаются фильтрами поверх постоянной вселенной (см. filterBar выше).
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, null)).catch((e) => console.error('[fg-selftest] не загрузился', e));
}
screen.cleanup = () => {
graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock');
};
return screen;
}

View File

@ -1,150 +0,0 @@
// Автопроверки интерактивного графа связей (dev-only).
//
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
//
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
export async function runNetworkSelfTest(graph, deepChipEl) {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
const st = () => graph.debugState();
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
await wait(1700);
let s = st();
const focusId = s.focusId;
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
graph.diveTo({ id: parent.id });
const framesA = graph.pumpForTest();
s = st();
const p = s.nodes.find((n) => n.id === parent.id);
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
// расталкивание: дети не слипаются
let minD = Infinity;
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
}
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
const outward = Math.atan2(p.y, p.x);
const maxDev = kids.reduce((mx, k) => {
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
if (d > Math.PI) d = 2 * Math.PI - d;
return Math.max(mx, d * 180 / Math.PI);
}, 0);
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
const offPath = tier1.filter((n) => n.id !== parent.id);
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
if (offPath.length) {
graph.diveTo({ id: offPath[0].id });
graph.pumpForTest();
s = st();
const prev = s.nodes.find((n) => n.id === parent.id);
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
}
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
const t2withKids = st().nodes.find((n) => n.tier === 2);
if (t2withKids) {
graph.diveTo({ id: t2withKids.id });
graph.pumpForTest();
s = st();
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
}
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
if (named && typeof graph.findNode === 'function') {
const byId = graph.findNode(named.id);
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
}
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
if (typeof graph.getDivePath === 'function' && t2withKids) {
const path = graph.getDivePath();
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' ')}`);
}
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
if (typeof document !== 'undefined') {
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
}
// === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) ===
if (typeof document !== 'undefined') {
const commonCount = document.querySelectorAll('.fg-node.is-common').length;
check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`);
}
// === Тест J: доступность — текстовый список графа для скринридеров ===
if (typeof document !== 'undefined') {
const a11y = document.querySelector('.fg-a11y');
const liCount = a11y ? a11y.querySelectorAll('li').length : 0;
check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`);
}
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
graph.exitDive();
graph.pumpForTest();
s = st();
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
if (typeof document !== 'undefined') {
const flare = document.querySelectorAll('.fg-plasma-flare');
const tube = document.querySelectorAll('.fg-plasma-tube');
const core = document.querySelectorAll('.fg-plasma-core');
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
}
return finish(results);
}
function finish(results) {
const pass = results.filter((r) => r.pass).length;
const out = { pass, total: results.length, results };
if (typeof window !== 'undefined') window.__fgTestResults = out;
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
// eslint-disable-next-line no-console
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name}${r.detail}`));
return out;
}