diff --git a/.gitignore b/.gitignore index b3b6272..914dba7 100644 --- a/.gitignore +++ b/.gitignore @@ -93,7 +93,3 @@ ESP32/**/*.a # Полные серверные бэкапы (тяжёлые архивы, не коммитим) server-backup/archive/** !server-backup/archive/.gitkeep - -# Лаборатория карты связей — только для локальной разработки (на сервере не нужна). -# Файл остаётся локально и работает; network-view.js грузит его динамически (с фолбэком). -shine-UI/js/pages/network/lab.js diff --git a/VERSION.properties b/VERSION.properties index 36e913f..f5d162c 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.140 +client.version=1.2.141 server.version=1.2.127 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 62a17ca..972a472 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -9,8 +9,6 @@ - `js/pages/network/adapter.js` — реальные данные → нейтральная модель движка. - `js/pages/network/node-menu.js` — общее контекстное меню узла. - `js/pages/network/lab.js` — лаборатория (`network-view/lab`) на мок-данных, без бэкенда. - **Только локально** (в `.gitignore`, в репозитории её нет): на сервере она не нужна. `network-view.js` - грузит её **динамическим** импортом — если файла нет (прод), реальный экран «Связи» не ломается. - `js/pages/network-view.js` — страница: шапка, поиск, фильтры, история, склейка с движком. - `js/mock-data.js` — `networkGraphUsers` (связанный мульти-граф из 10 человек для лаборатории). - `styles/network-graph.css` — все стили `.fg-*`. diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index e7f57f5..595f53c 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -2,6 +2,7 @@ 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'; @@ -216,19 +217,9 @@ let persistedCenterLogin = ''; let persistedCenterHistory = []; export function render({ navigate, route }) { - // Лабораторный режим force-графа — ТОЛЬКО для локальной разработки. Файл `network/lab.js` - // намеренно НЕ в гите (см. .gitignore): на сервере он не нужен (там реальные данные). Поэтому - // импорт ДИНАМИЧЕСКИЙ — если файла нет (прод/сервер), реальный экран «Связи» не ломается, - // а заход на /network-view/lab просто уводит на обычный экран. + // Лабораторный режим force-графа (Фаза 1): рендерится из мока, не трогая реальный путь ниже. if (String(route?.params?.mode || '').trim().toLowerCase() === 'lab') { - const host = document.createElement('div'); - host.style.display = 'contents'; // прозрачная обёртка: lab-экран ведёт себя как прямой потомок - let inner = null; - import('./network/lab.js') - .then((m) => { inner = m.renderNetworkLab({ navigate }); host.append(inner); }) - .catch(() => { navigate('network-view'); }); // нет файла (сервер) — на реальный экран - host.cleanup = () => { if (inner && typeof inner.cleanup === 'function') inner.cleanup(); }; - return host; + return renderNetworkLab({ navigate }); } const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const routeLogin = normalizeLogin(route?.params?.login || ''); diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js new file mode 100644 index 0000000..dc2a655 --- /dev/null +++ b/shine-UI/js/pages/network/lab.js @@ -0,0 +1,131 @@ +// Лабораторный режим карты связей (network-view/lab). +// +// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование, +// центрирование и навигацию между пользователями. Используется связанный мульти-граф +// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает +// карту на сеть этого человека (как реальный путь, но локально). + +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']; + +function helpText() { + return [ + 'Лаборатория карты связей (мок-данные, без сервера).', + '• Тащите по экрану — карта свободно перемещается (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: [] }; +} + +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'); + + const model = buildModelFromTz(graphForLogin(START_LOGIN)); + + // Состояние активного слоя (как в 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); + + // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом + // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. + const graph = createForceGraph({ + stage, + model, + // тап по узлу — переключаем карту на сеть выбранного человека + onNodeTap: (node) => { + graph.setModel(buildModelFromTz(graphForLogin(node.login))); + // сохраняем выбранный слой при переходе на сеть другого человека (как в network-view) + if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); + }, + onCenterTap: (node) => 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'; + // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает 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); + }); + stage.append(filterBar); + + screen.cleanup = () => { + graph.destroy(); + appScreenEl?.classList.remove('network-scroll-lock'); + }; + + return screen; +}