From 465792b2abc452bd8ea1ab3e367284e625f4ab6b111c0fc0c7fed7d2710a04ad Mon Sep 17 00:00:00 2001 From: Pixel Date: Fri, 19 Jun 2026 20:31:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4:=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84-=D0=BB=D0=B0?= =?UTF-8?q?=D0=B1=D1=83=20(network/lab.js,=20selftest.js,=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84-=D0=BC=D0=BE=D0=BA=20=D0=B2=20mock-data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- shine-UI/js/mock-data.js | 155 +----------- shine-UI/js/pages/network-view.js | 5 - shine-UI/js/pages/network/lab.js | 333 -------------------------- shine-UI/js/pages/network/selftest.js | 150 ------------ 4 files changed, 8 insertions(+), 635 deletions(-) delete mode 100644 shine-UI/js/pages/network/lab.js delete mode 100644 shine-UI/js/pages/network/selftest.js diff --git a/shine-UI/js/mock-data.js b/shine-UI/js/mock-data.js index 5199e4b..250081c 100644 --- a/shine-UI/js/mock-data.js +++ b/shine-UI/js/mock-data.js @@ -49,21 +49,15 @@ export const deviceSessions = [ // unreadCount: number; preview: string // toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать // (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо) -// ЛС-демо (мок). Поля СЕМАНТИЧЕСКИЕ (без хранения цвета) — визуал решает dm-visual-resolver.js. -// Набор покрывает ровно матрицу состояний M01–M06 из спеки (по одному кейсу на строку). +// ЛС — мок-плейсхолдер (на проде заменяется реальными relations/chats). Поля СЕМАНТИЧЕСКИЕ +// (без хранения цвета) — визуал решает dm-visual-resolver.js. Аватары — через профиль (инициалы, пока нет фото). 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, photo: '/assets/demo-avatars/u1.jpg' }, - // M02 — обычная активная связь: violet-обод, изумрудная капсула «Связь», отдельная violet-сфера «2». - { 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' }] }, - // M03 — сияющий с активной связью: мини-сфера языка «Связи», изумруд «Связь», violet-сфера «5». - { 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' }] }, - // 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' }] }, + { id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 }, + { 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: 'Павел С.' }] }, + { 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: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0 }, + { id: 'u6', login: 'pavel', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 }, + { 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: 'Марина К.' }] }, ]; 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), - ]), -}; diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 595f53c..6c71171 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -2,7 +2,6 @@ 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'; @@ -217,10 +216,6 @@ 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) { diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js deleted file mode 100644 index 6b05168..0000000 --- a/shine-UI/js/pages/network/lab.js +++ /dev/null @@ -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; -} diff --git a/shine-UI/js/pages/network/selftest.js b/shine-UI/js/pages/network/selftest.js deleted file mode 100644 index 4c0dd22..0000000 --- a/shine-UI/js/pages/network/selftest.js +++ /dev/null @@ -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; -}