SHiNE-server/shine-UI/js/pages/network/lab.js
Pixel 72dc83daff Связи (pixel-web): этап 2 паутины — hover-превью + collision + zoom + камера-доводчик + синхро-пульс
Доработка режима «Интерактивная паутина» (только лаборатория, 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>
2026-06-09 22:34:26 +03:00

258 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Лабораторный режим карты связей (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;
}