Доработка режима «Интерактивная паутина» (только лаборатория, deep-режим «Вселенная»): Взаимодействие (по запросу): наведение ≠ клик. - Hover-превью: навёл мышь/палец на узел — его ветка ВРЕМЕННО выплывает; убрал — втягивается. (pointerover/out для мыши, pointerdown/up для пальца → onNodeHover → graph.setHover; флаг hovered). - Фиксация кликом: тап/клик → graph.toggleExpand ставит pinned — ветка остаётся раскрытой и после ухода курсора; повторный тап снимает фиксацию. Эффект = pinned || hovered (expandTargetOf). Этап 2 «Мегамасштаб»: - Collision-расталкивание: раскрытая ветка усиливает отталкивание соседей 1-го уровня пропорционально expandP (EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь. - Свободный зум: колесо мыши (onWheel) + щипок двумя пальцами (activePointers/pinching), zoom 0.55–2.6 «к точке»; мир — CSS-scale, линии (SVG) пересчитываются в экранных координатах × zoom. - Камера-доводчик: при фиксации ветки, если её веер упирается в край, камера мягко дотягивается (glideCameraTo → camTargetX/Y, lerp CAM_GLIDE_K в tick); любой жест отменяет доводчик. - Синхро-пульс: сияющие/трековые «световоды» дышат толщиной/размытием 3.6с в такт ободку узла. Реальный путь /network-view не затронут: deep-код под tier≥2/hasDeep, hover-колбэк даёт только лаборатория. Ветка экспериментальная (отдельно от pixel-08.06/PR). Бамп client.version → 1.2.144. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
14 KiB
JavaScript
258 lines
14 KiB
JavaScript
// Лабораторный режим карты связей (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 уровней, фейковые связи).',
|
||
' По умолчанию дальние связи скрыты. Наведи мышь/палец на узел — его микро-связи временно',
|
||
' выплывают (превью), убери — втягиваются. Кликни/тапни узел — раскрытие ФИКСИРУЕТСЯ.',
|
||
' Тап по центру (Ивану) — свернуть все ветки. Колесо мыши / щипок — зум. Свайп — 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
|
||
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;
|
||
extra.push({
|
||
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
|
||
avatar: null, photo: null, 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;
|
||
extra.push({
|
||
id: id3, login: id3, name: '', avatar: null, photo: null, relationType: 'contact',
|
||
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
return { focusId, nodes: [...model.nodes, ...extra] };
|
||
}
|
||
|
||
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
|
||
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
|
||
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
|
||
function buildLabModel(login, deep, fromLogin) {
|
||
const tz = graphForLogin(login);
|
||
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
|
||
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
|
||
else tz.connections = [];
|
||
}
|
||
const base = buildModelFromTz(tz);
|
||
|
||
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
|
||
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
|
||
const fid = String(fromLogin);
|
||
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|
||
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
|
||
if (found) {
|
||
found.track = true; // уже среди связей — просто подсветим трек
|
||
} else {
|
||
const f = graphForLogin(fromLogin).focusUser || {};
|
||
base.nodes.push({
|
||
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
|
||
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
|
||
photo: f.photo || null, relationType: 'friend', strength: 0.97,
|
||
shining: false, tier: 1, track: true,
|
||
});
|
||
}
|
||
}
|
||
return deep ? addDeepLevels(base) : base;
|
||
}
|
||
|
||
export function renderNetworkLab({ navigate }) {
|
||
const screen = document.createElement('section');
|
||
screen.className = 'network-screen';
|
||
|
||
const appScreenEl = document.getElementById('app-screen');
|
||
appScreenEl?.classList.add('network-scroll-lock');
|
||
|
||
const stage = document.createElement('div');
|
||
stage.className = 'network-stage fg-stage';
|
||
|
||
const header = renderHeader({
|
||
title: 'Связи · лаборатория',
|
||
leftAction: { label: '←', onClick: () => navigate('network-view') },
|
||
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
|
||
});
|
||
header.classList.add('network-header-overlay');
|
||
|
||
let centerLogin = START_LOGIN;
|
||
let deepMode = false;
|
||
|
||
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
||
let activeFilter = 'all';
|
||
const filterChips = {};
|
||
function applyFilter(key) {
|
||
if (!FILTERS[key]) return;
|
||
activeFilter = key;
|
||
FILTER_ORDER.forEach((k) => {
|
||
const el = filterChips[k];
|
||
if (el) el.classList.toggle('is-active', k === activeFilter);
|
||
});
|
||
graph.setFilter(FILTERS[key].pred);
|
||
}
|
||
|
||
stage.append(header);
|
||
screen.append(stage);
|
||
|
||
const model = buildLabModel(centerLogin, deepMode);
|
||
|
||
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
||
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
||
const graph = createForceGraph({
|
||
stage,
|
||
model,
|
||
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
|
||
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
||
onNodeTap: (node) => {
|
||
if (deepMode) {
|
||
// режим «Интерактивная паутина»: НЕ меняем центр — клик ФИКСИРУЕТ раскрытие ветки (остаётся
|
||
// открытой и после ухода курсора); повторный клик снимает фиксацию.
|
||
graph.toggleExpand(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); },
|
||
onCenterTap: (node) => {
|
||
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
||
if (deepMode) { graph.collapseAll(); return; }
|
||
window.alert(`Профиль: ${node.name || node.login || node.id}`);
|
||
},
|
||
onNodeLongPress: (node, point) => openNodeMenu({
|
||
login: node.name || node.login || node.id,
|
||
relationType: node.relationType,
|
||
point,
|
||
actions: [
|
||
{ label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) },
|
||
{ label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) },
|
||
],
|
||
}),
|
||
});
|
||
|
||
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
||
const filterBar = document.createElement('div');
|
||
filterBar.className = 'fg-filter-bar';
|
||
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
|
||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||
FILTER_ORDER.forEach((key) => {
|
||
const chip = document.createElement('button');
|
||
chip.type = 'button';
|
||
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
|
||
chip.textContent = FILTERS[key].label;
|
||
chip.addEventListener('click', () => applyFilter(key));
|
||
filterChips[key] = chip;
|
||
filterBar.append(chip);
|
||
});
|
||
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
|
||
const deepChip = document.createElement('button');
|
||
deepChip.type = 'button';
|
||
deepChip.className = 'fg-filter-chip fg-deep-chip';
|
||
deepChip.textContent = '🌌 Вселенная';
|
||
deepChip.addEventListener('click', () => {
|
||
deepMode = !deepMode;
|
||
deepChip.classList.toggle('is-active', deepMode);
|
||
graph.setModel(buildLabModel(centerLogin, deepMode));
|
||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||
});
|
||
filterBar.append(deepChip);
|
||
stage.append(filterBar);
|
||
|
||
screen.cleanup = () => {
|
||
graph.destroy();
|
||
appScreenEl?.classList.remove('network-scroll-lock');
|
||
};
|
||
|
||
return screen;
|
||
}
|