diff --git a/VERSION.properties b/VERSION.properties index bf06daa..003bbd1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.155 -server.version=1.2.147 +client.version=1.2.159 +server.version=1.2.148 diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index dc2a655..c7cc6d6 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -4,6 +4,10 @@ // центрирование и навигацию между пользователями. Используется связанный мульти-граф // networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает // карту на сеть этого человека (как реальный путь, но локально). +// +// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи +// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем). +// Это чисто визуальный лабораторный эксперимент на мок-данных. import { renderHeader } from '../../components/header.js'; import { networkGraphUsers } from '../../mock-data.js'; @@ -22,6 +26,18 @@ const FILTERS = { }; 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 [ 'Лаборатория карты связей (мок-данные, без сервера).', @@ -30,6 +46,13 @@ function helpText() { '• Тап по центральному узлу — здесь открылся бы профиль.', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', + '• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).', + ' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный', + ' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,', + ' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.', + ' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).', + ' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня', + ' превращаются в аватарки. Свайп — pan.', '', 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', ].join('\n'); @@ -42,6 +65,104 @@ function graphForLogin(login) { 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'; @@ -54,17 +175,13 @@ export function renderNetworkLab({ navigate }) { const header = renderHeader({ title: 'Связи · лаборатория', - leftAction: { - label: '←', - onClick: () => navigate('network-view'), - }, - rightActions: [ - { label: '?', onClick: () => window.alert(helpText()) }, - ], + leftAction: { label: '←', onClick: () => navigate('network-view') }, + rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }], }); header.classList.add('network-header-overlay'); - const model = buildModelFromTz(graphForLogin(START_LOGIN)); + let centerLogin = START_LOGIN; + let deepMode = false; // Состояние активного слоя (как в network-view): фокус всегда виден. let activeFilter = 'all'; @@ -82,18 +199,41 @@ export function renderNetworkLab({ navigate }) { 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) => { - graph.setModel(buildModelFromTz(graphForLogin(node.login))); - // сохраняем выбранный слой при переходе на сеть другого человека (как в network-view) + 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); }, - onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`), + // Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором, + // втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная». + 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, @@ -108,8 +248,7 @@ export function renderNetworkLab({ navigate }) { // Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view. const filterBar = document.createElement('div'); filterBar.className = 'fg-filter-bar'; - // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage, - // а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает. + // Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click). filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); FILTER_ORDER.forEach((key) => { const chip = document.createElement('button'); @@ -120,8 +259,79 @@ export function renderNetworkLab({ navigate }) { 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');