Связи: интерактивная карта связей (force-directed graph)
Переработка экрана «Связи» в интерактивный нод-граф с премиальными переходами. Движок (js/pages/network/force-graph.js): - diffing-переходы: общие узлы перелетают, новые расцветают каскадом, исчезнувшие — Ghost-слой (800мс, на месте); - мягкая радиальная пружина + отталкивание (органичная орбита), упругий влёт фокуса; - динамическая вязкость на старте (трение 0.92→0.82, отталкивание ослаблено) — мягкий разлёт без тряски; - жёсткая заморозка (kill-switch) при затухании — нет «треска», экономия батареи; - линии — SVG <path> Безье (изогнутые нити), прорастание; жесты pan с инерцией; - хард-лимит DOM-аватарок (остальное — SVG-точки). Интеграция и UX: - adapter.js: getUserConnectionsGraph → модель движка (сервер не трогаем, read-only); - фильтры (Все/Семья/Друзья/Сияющие), контекстное меню (node-menu.js), нижний сниппет, профиль; - прицел в центре, дыхание фокуса, свечение сияющих; - лаборатория network-view/lab на мок-данных (networkGraphUsers) для тестов без бэкенда. Документация: shine-UI/Dev_Docs/features/interactive-network-graph.md. Бамп client.version 1.2.135 -> 1.2.136. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
885cf463a7
commit
f56e531384
@ -1,2 +1,2 @@
|
||||
client.version=1.2.135
|
||||
client.version=1.2.136
|
||||
server.version=1.2.127
|
||||
|
||||
72
shine-UI/Dev_Docs/features/interactive-network-graph.md
Normal file
72
shine-UI/Dev_Docs/features/interactive-network-graph.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Интерактивная карта связей (force-directed graph)
|
||||
|
||||
Экран **«Связи»** (`network-view`) — интерактивная нод-граф карта вместо статичного списка:
|
||||
фокусный пользователь в центре, связи на орбите, навигация тапом/свайпом, премиальные
|
||||
переходы в духе нативного iOS.
|
||||
|
||||
## Где код
|
||||
- `js/pages/network/force-graph.js` — **движок** (физика, рендер, жизненный цикл узлов, жесты).
|
||||
- `js/pages/network/adapter.js` — реальные данные → нейтральная модель движка.
|
||||
- `js/pages/network/node-menu.js` — общее контекстное меню узла.
|
||||
- `js/pages/network/lab.js` — лаборатория (`network-view/lab`) на мок-данных, без бэкенда.
|
||||
- `js/pages/network-view.js` — страница: шапка, поиск, фильтры, история, склейка с движком.
|
||||
- `js/mock-data.js` — `networkGraphUsers` (связанный мульти-граф из 10 человек для лаборатории).
|
||||
- `styles/network-graph.css` — все стили `.fg-*`.
|
||||
|
||||
## Данные (read-only, сервер не трогаем)
|
||||
Единый источник — `authService.getUserConnectionsGraph(login)` (один запрос: логин → прямые связи).
|
||||
`network-view.js` → `buildGraphModel()` нормализует роли (parent/child/sibling/spouse/friend/contact),
|
||||
направление и метки; `adapter.engineModelFromGraphModel()` превращает это в модель движка:
|
||||
`{ focusId, nodes:[{ id, login, name, avatar, relationType, strength, shining, tier }] }`.
|
||||
|
||||
## Модель движка и API
|
||||
`createForceGraph({ stage, model, onNodeTap, onCenterTap, onNodeLongPress })` →
|
||||
`{ setModel(model), setFilter(pred), recenter(id), getFocusNode(), destroy() }`.
|
||||
|
||||
## Ключевые механики
|
||||
- **Diffing-переходы (непрерывность состояний):** при смене фокуса общие узлы (тот же `id`) не
|
||||
пересоздаются, а перелетают пружиной на новые места; новые «расцветают» (bloom) каскадом из центра;
|
||||
исчезнувшие уходят в Ghost-слой.
|
||||
- **Ghost-слой:** снимок всего старого графа (узлы + линии) на полноэкранном overlay, застывает
|
||||
на месте, `scale 1→0.7` + `opacity 0.5→0` за **800мс**, затем удаляется (красивый шлейф истории).
|
||||
- **Физика:** мягкая радиальная пружина к орбите + взаимное отталкивание (charge) → органичная,
|
||||
слегка неровная орбита; фокус влетает в центр упруго. Координаты узлов на трансформах (GPU).
|
||||
- **Каскадный bloom:** новые узлы скрыты в центре и «выстреливают» по очереди (`order × 40мс`).
|
||||
- **Динамическая вязкость:** первые ~600мс после перестроения трение завышено (0.92), отталкивание
|
||||
ослаблено (×0.45) → гасит «взрыв», затем плавно к базе (0.82) — мягкое «резиновое» появление.
|
||||
- **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 — скорости обнуляются, координаты
|
||||
округляются до целых пикселей, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает.
|
||||
- **Линии:** SVG `<path> Q` (квадратичные Безье) — изящные изогнутые нити, тонкие/полупрозрачные;
|
||||
при движении изгиб реагирует на скорость; новые линии прорастают (`stroke-dashoffset`).
|
||||
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
||||
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
||||
по центру — профиль.
|
||||
- **Фильтры слоёв:** Все / Семья / Друзья / Сияющие (плавное скрытие/перераспределение).
|
||||
- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса), «дыхание» фокуса (бесконечная CSS-анимация
|
||||
размера 1.48–1.52x, GPU, не будит rAF), свечение «сияющих», хард-лимит ~90 DOM-аватарок (остальное —
|
||||
SVG-точки).
|
||||
|
||||
## Параметры тюнинга (константы в начале `force-graph.js`)
|
||||
| Константа | Значение | Назначение |
|
||||
|---|---|---|
|
||||
| `ORBIT_MIN / ORBIT_MAX` | 150 / 240 | радиус орбиты (защитный отступ от центра — подписи не наезжают) |
|
||||
| `K_RADIAL` | 0.035 | жёсткость орбитальной пружины (мягко) |
|
||||
| `K_FOCUS` | 0.12 | жёсткость пружины фокуса к центру |
|
||||
| `CHARGE` / `CHARGE_START_FACTOR` | 1400 / 0.45 | отталкивание (на старте ослаблено) |
|
||||
| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.82 / 0.92 / 36 | базовое трение / стартовая вязкость / длительность (~600мс) |
|
||||
| `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки |
|
||||
| `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса |
|
||||
| `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) |
|
||||
|
||||
## Локальный запуск / проверка
|
||||
- Dev-сервер: `.claude/shine-ui-dev-server.cjs` (Node, порт 7321, SPA-fallback + инжект `<base href="/">`).
|
||||
- Лаборатория (без бэкенда): `http://localhost:7321/network-view/lab` — мок `networkGraphUsers`,
|
||||
тап по узлам переключает сети.
|
||||
- Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма).
|
||||
|
||||
## Ограничения / на будущее
|
||||
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
|
||||
упираются в API (отдаёт только прямые связи) — требуют доработки сервера.
|
||||
- `lerpX/lerpY` в движке больше не используются для отрисовки — кандидат на чистку.
|
||||
- Превью в простое троттлит `requestAnimationFrame` (физика не идёт между вызовами) — для замеров
|
||||
прокачивать кадры; в активном табе всё работает на 60 FPS.
|
||||
@ -12,7 +12,7 @@
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css'];
|
||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css'];
|
||||
cssFiles.forEach((file) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
@ -280,3 +280,116 @@ export const networkGraph = {
|
||||
{ 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']);
|
||||
|
||||
function networkConn(login, relationType, connectionStrength) {
|
||||
return {
|
||||
id: login,
|
||||
login,
|
||||
name: NETWORK_NAMES[login] || login,
|
||||
avatar: 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,
|
||||
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),
|
||||
]),
|
||||
};
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.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';
|
||||
|
||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||
|
||||
const GENDER_MALE = 'male';
|
||||
const GENDER_FEMALE = 'female';
|
||||
const GENDER_UNKNOWN = 'unknown';
|
||||
const CENTER_NODE_ID = '__center__';
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim();
|
||||
@ -111,147 +112,6 @@ function getRelativeGenderMap(graph) {
|
||||
return map;
|
||||
}
|
||||
|
||||
function relativeRoleLabel(role, gender) {
|
||||
const cleanGender = normalizeGender(gender);
|
||||
if (role === 'parent') {
|
||||
if (cleanGender === GENDER_MALE) return 'отец';
|
||||
if (cleanGender === GENDER_FEMALE) return 'мать';
|
||||
return 'родитель';
|
||||
}
|
||||
if (role === 'child') {
|
||||
if (cleanGender === GENDER_MALE) return 'сын';
|
||||
if (cleanGender === GENDER_FEMALE) return 'дочь';
|
||||
return 'потомок';
|
||||
}
|
||||
if (role === 'sibling') {
|
||||
if (cleanGender === GENDER_MALE) return 'брат';
|
||||
if (cleanGender === GENDER_FEMALE) return 'сестра';
|
||||
return 'брат/сестра';
|
||||
}
|
||||
if (role === 'spouse') {
|
||||
if (cleanGender === GENDER_MALE) return 'муж';
|
||||
if (cleanGender === GENDER_FEMALE) return 'жена';
|
||||
return 'жена/муж';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildNameLines(firstName, lastName) {
|
||||
const first = String(firstName || '').trim();
|
||||
const last = String(lastName || '').trim();
|
||||
if (first && last) {
|
||||
const full = `${first} ${last}`.trim();
|
||||
if (full.length <= 20) return [full];
|
||||
return [first, last];
|
||||
}
|
||||
if (first) return [first];
|
||||
if (last) return [last];
|
||||
return [];
|
||||
}
|
||||
|
||||
function applyNodeText(node, {
|
||||
login,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
role = 'friend',
|
||||
gender = GENDER_UNKNOWN,
|
||||
mark = null,
|
||||
} = {}) {
|
||||
const loginText = normalizeLogin(login);
|
||||
const labelsWrap = node.querySelector('.node-label');
|
||||
const nameEl = node.querySelector('.node-name');
|
||||
const loginEl = node.querySelector('.node-login');
|
||||
const relationEl = node.querySelector('.node-relation');
|
||||
if (!(labelsWrap instanceof HTMLElement) || !(nameEl instanceof HTMLElement) || !(loginEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameLines = buildNameLines(firstName, lastName);
|
||||
nameEl.innerHTML = '';
|
||||
if (nameLines.length) {
|
||||
nameLines.forEach((line) => {
|
||||
const lineEl = document.createElement('span');
|
||||
lineEl.className = 'node-name-line';
|
||||
lineEl.textContent = line;
|
||||
nameEl.append(lineEl);
|
||||
});
|
||||
labelsWrap.classList.remove('is-login-only');
|
||||
} else {
|
||||
labelsWrap.classList.add('is-login-only');
|
||||
}
|
||||
loginEl.textContent = loginText;
|
||||
|
||||
const relLabel = relativeRoleLabel(role, gender);
|
||||
if (relationEl instanceof HTMLElement) {
|
||||
relationEl.textContent = relLabel;
|
||||
relationEl.hidden = !relLabel;
|
||||
}
|
||||
|
||||
const metaParts = [];
|
||||
if (mark?.officialLabel) metaParts.push(mark.officialLabel);
|
||||
if (mark?.shineLabel) metaParts.push(mark.shineLabel);
|
||||
if (relLabel) metaParts.push(`роль: ${relLabel}`);
|
||||
const titleMain = nameLines.length ? `${nameLines.join(' ')} (${loginText})` : loginText;
|
||||
node.title = metaParts.length ? `${titleMain}\n${metaParts.join(', ')}` : titleMain;
|
||||
}
|
||||
|
||||
function spread(count, start, end) {
|
||||
if (count <= 0) return [];
|
||||
if (count === 1) return [(start + end) / 2];
|
||||
const out = [];
|
||||
const step = (end - start) / (count - 1);
|
||||
for (let i = 0; i < count; i += 1) out.push(start + step * i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildNodeElement({
|
||||
login,
|
||||
kind = 'friend',
|
||||
isCenter = false,
|
||||
mark = null,
|
||||
role = 'friend',
|
||||
gender = GENDER_UNKNOWN,
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
}) {
|
||||
const node = document.createElement('button');
|
||||
node.type = 'button';
|
||||
const classes = [
|
||||
'node',
|
||||
isCenter ? 'center' : '',
|
||||
kind === 'relative' ? 'is-relative' : 'is-friend',
|
||||
mark?.shine ? 'is-shine' : '',
|
||||
mark?.official ? 'is-official' : '',
|
||||
].filter(Boolean);
|
||||
node.className = classes.join(' ');
|
||||
node.dataset.nodeLogin = login;
|
||||
|
||||
if (mark?.official) {
|
||||
const officialBadge = document.createElement('span');
|
||||
officialBadge.className = 'node-badge-official';
|
||||
officialBadge.setAttribute('aria-hidden', 'true');
|
||||
officialBadge.textContent = 'ОФ';
|
||||
node.append(officialBadge);
|
||||
}
|
||||
node.append(renderUserAvatar({
|
||||
login,
|
||||
avatar: mark?.avatar || null,
|
||||
size: 'node',
|
||||
title: login,
|
||||
}));
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'node-label';
|
||||
label.innerHTML = `
|
||||
<span class="node-name"></span>
|
||||
<span class="node-login"></span>
|
||||
<span class="node-relation" hidden></span>
|
||||
`;
|
||||
node.append(label);
|
||||
applyNodeText(node, { login, firstName, lastName, role, gender, mark });
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildGraphModel(graph, centerLogin) {
|
||||
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
|
||||
const outFriends = toSet(graph?.outFriends);
|
||||
@ -264,6 +124,13 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const inSiblings = toSet(graph?.inSiblings);
|
||||
const outSpouses = toSet(graph?.outSpouses);
|
||||
const inSpouses = toSet(graph?.inSpouses);
|
||||
// контакты/подписки/знакомые — для слоя «Все контакты» (Фаза 3)
|
||||
const outContacts = toSet(graph?.outContacts);
|
||||
const inContacts = toSet(graph?.inContacts);
|
||||
const outFollows = toSet(graph?.outFollows);
|
||||
const inFollows = toSet(graph?.inFollows);
|
||||
const outKnown = toSet(graph?.outKnownPersons);
|
||||
const inKnown = toSet(graph?.inKnownPersons);
|
||||
|
||||
const relativesGender = getRelativeGenderMap(graph);
|
||||
const allMarks = getMarkByLogin(graph?.allUsers);
|
||||
@ -279,6 +146,12 @@ function buildGraphModel(graph, centerLogin) {
|
||||
...(graph?.inSiblings || []),
|
||||
...(graph?.outSpouses || []),
|
||||
...(graph?.inSpouses || []),
|
||||
...(graph?.outContacts || []),
|
||||
...(graph?.inContacts || []),
|
||||
...(graph?.outFollows || []),
|
||||
...(graph?.inFollows || []),
|
||||
...(graph?.outKnownPersons || []),
|
||||
...(graph?.inKnownPersons || []),
|
||||
]).filter((entry) => normKey(entry) !== normKey(login));
|
||||
|
||||
const relations = allLogins.map((targetLogin) => {
|
||||
@ -292,12 +165,15 @@ function buildGraphModel(graph, centerLogin) {
|
||||
const spouseIn = hasLogin(inSpouses, targetLogin);
|
||||
const friendOut = hasLogin(outFriends, targetLogin);
|
||||
const friendIn = hasLogin(inFriends, targetLogin);
|
||||
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
|
||||
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, targetLogin);
|
||||
|
||||
let role = 'friend';
|
||||
let role = 'contact';
|
||||
if (parentOut || parentIn) role = 'parent';
|
||||
else if (childOut || childIn) role = 'child';
|
||||
else if (spouseOut || spouseIn) role = 'spouse';
|
||||
else if (siblingOut || siblingIn) role = 'sibling';
|
||||
else if (friendOut || friendIn) role = 'friend';
|
||||
|
||||
let forward = friendOut;
|
||||
let backward = friendIn;
|
||||
@ -313,13 +189,16 @@ function buildGraphModel(graph, centerLogin) {
|
||||
} else if (role === 'sibling') {
|
||||
forward = siblingOut;
|
||||
backward = siblingIn;
|
||||
} else if (role === 'contact') {
|
||||
forward = contactOut;
|
||||
backward = contactIn;
|
||||
}
|
||||
|
||||
return {
|
||||
login: targetLogin,
|
||||
key: normKey(targetLogin),
|
||||
role,
|
||||
isRelative: role !== 'friend',
|
||||
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
|
||||
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
|
||||
forward: Boolean(forward),
|
||||
backward: Boolean(backward),
|
||||
@ -334,188 +213,14 @@ function buildGraphModel(graph, centerLogin) {
|
||||
};
|
||||
}
|
||||
|
||||
function splitByGender(list) {
|
||||
const left = [];
|
||||
const right = [];
|
||||
const center = [];
|
||||
list.forEach((item) => {
|
||||
if (item.gender === GENDER_FEMALE) left.push(item);
|
||||
else if (item.gender === GENDER_MALE) right.push(item);
|
||||
else center.push(item);
|
||||
});
|
||||
return { left, right, center };
|
||||
}
|
||||
|
||||
function sortByLogin(items) {
|
||||
return [...items].sort((a, b) => a.login.localeCompare(b.login, 'ru', { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
function positionRows(nodes, x, yStart, yEnd) {
|
||||
const ys = spread(nodes.length, yStart, yEnd);
|
||||
return nodes.map((node, index) => ({ ...node, x, y: ys[index] }));
|
||||
}
|
||||
|
||||
function layoutNodes(model) {
|
||||
const centerNode = {
|
||||
id: CENTER_NODE_ID,
|
||||
login: model.centerLogin,
|
||||
x: 50,
|
||||
y: 50,
|
||||
isCenter: true,
|
||||
kind: 'center',
|
||||
relation: null,
|
||||
gender: GENDER_UNKNOWN,
|
||||
mark: model.centerMark,
|
||||
};
|
||||
|
||||
const parents = sortByLogin(model.relations.filter((item) => item.role === 'parent'));
|
||||
const children = sortByLogin(model.relations.filter((item) => item.role === 'child'));
|
||||
const spouses = sortByLogin(model.relations.filter((item) => item.role === 'spouse'));
|
||||
const siblings = sortByLogin(model.relations.filter((item) => item.role === 'sibling'));
|
||||
const friends = sortByLogin(model.relations.filter((item) => item.role === 'friend'));
|
||||
|
||||
const parentSplit = splitByGender(parents);
|
||||
const childSplit = splitByGender(children);
|
||||
const spouseSplit = splitByGender(spouses);
|
||||
const siblingSplit = splitByGender(siblings);
|
||||
|
||||
const friendLeft = [];
|
||||
const friendRight = [];
|
||||
friends.forEach((item, index) => {
|
||||
if (index % 2 === 0) friendLeft.push(item);
|
||||
else friendRight.push(item);
|
||||
});
|
||||
|
||||
const positioned = [
|
||||
...positionRows(parentSplit.left, 28, 16, 28),
|
||||
...positionRows(parentSplit.right, 72, 16, 28),
|
||||
...positionRows(parentSplit.center, 50, 10, 22),
|
||||
...positionRows(friendLeft, 12, 30, 70),
|
||||
...positionRows(friendRight, 88, 30, 70),
|
||||
...positionRows(spouseSplit.left, 36, 38, 58),
|
||||
...positionRows(spouseSplit.right, 64, 38, 58),
|
||||
...positionRows(spouseSplit.center, 50, 40, 56),
|
||||
...positionRows(siblingSplit.left, 30, 46, 66),
|
||||
...positionRows(siblingSplit.right, 70, 46, 66),
|
||||
...positionRows(siblingSplit.center, 50, 54, 70),
|
||||
...positionRows(childSplit.left, 28, 68, 84),
|
||||
...positionRows(childSplit.right, 72, 68, 84),
|
||||
...positionRows(childSplit.center, 50, 78, 88),
|
||||
];
|
||||
|
||||
const nodes = [centerNode];
|
||||
const edges = [];
|
||||
|
||||
positioned.forEach((item) => {
|
||||
const nodeId = item.key;
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
login: item.login,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
isCenter: false,
|
||||
kind: item.isRelative ? 'relative' : 'friend',
|
||||
relation: item.role,
|
||||
gender: item.gender,
|
||||
mark: item.mark,
|
||||
});
|
||||
edges.push({
|
||||
from: item.forward ? CENTER_NODE_ID : nodeId,
|
||||
to: item.forward ? nodeId : CENTER_NODE_ID,
|
||||
mutual: item.forward && item.backward,
|
||||
isRelative: item.isRelative,
|
||||
exists: item.forward || item.backward,
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function marker(svg, id, color) {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||||
el.setAttribute('id', id);
|
||||
el.setAttribute('viewBox', '0 0 10 10');
|
||||
el.setAttribute('refX', '9');
|
||||
el.setAttribute('refY', '5');
|
||||
el.setAttribute('markerUnits', 'strokeWidth');
|
||||
el.setAttribute('markerWidth', '6');
|
||||
el.setAttribute('markerHeight', '6');
|
||||
el.setAttribute('orient', 'auto');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
||||
path.setAttribute('fill', color);
|
||||
el.append(path);
|
||||
svg.append(el);
|
||||
}
|
||||
|
||||
function getNodeCenter(boardRect, node) {
|
||||
const dot = node.querySelector('.node-dot');
|
||||
if (!(dot instanceof HTMLElement)) return null;
|
||||
const rect = dot.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left - boardRect.left + (rect.width / 2),
|
||||
y: rect.top - boardRect.top + (rect.height / 2),
|
||||
radius: rect.width / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function shortenLine(fromPoint, toPoint, fromOffset, toOffset) {
|
||||
const dx = toPoint.x - fromPoint.x;
|
||||
const dy = toPoint.y - fromPoint.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1) return null;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
return {
|
||||
x1: fromPoint.x + (ux * fromOffset),
|
||||
y1: fromPoint.y + (uy * fromOffset),
|
||||
x2: toPoint.x - (ux * toOffset),
|
||||
y2: toPoint.y - (uy * toOffset),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEdges(svg, board, nodeElements, edges) {
|
||||
svg.innerHTML = '';
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
marker(defs, 'network-arrow-friend', 'rgba(120, 179, 255, 0.95)');
|
||||
marker(defs, 'network-arrow-relative', 'rgba(255, 159, 94, 0.95)');
|
||||
svg.append(defs);
|
||||
|
||||
const boardRect = board.getBoundingClientRect();
|
||||
const centers = new Map();
|
||||
nodeElements.forEach((value, key) => {
|
||||
const pt = getNodeCenter(boardRect, value);
|
||||
if (pt) centers.set(key, pt);
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (!edge.exists) return;
|
||||
const from = centers.get(edge.from);
|
||||
const to = centers.get(edge.to);
|
||||
if (!from || !to) return;
|
||||
|
||||
const cut = shortenLine(from, to, from.radius + 3, to.radius + 3);
|
||||
if (!cut) return;
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('x1', String(cut.x1));
|
||||
line.setAttribute('y1', String(cut.y1));
|
||||
line.setAttribute('x2', String(cut.x2));
|
||||
line.setAttribute('y2', String(cut.y2));
|
||||
line.setAttribute('class', `network-link ${edge.isRelative ? 'is-relative' : 'is-friend'}`);
|
||||
|
||||
if (!edge.mutual) {
|
||||
line.setAttribute('marker-end', `url(#${edge.isRelative ? 'network-arrow-relative' : 'network-arrow-friend'})`);
|
||||
}
|
||||
svg.append(line);
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -532,14 +237,35 @@ export function render({ navigate, route }) {
|
||||
stage.className = 'network-stage';
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'network-board network-board--full';
|
||||
board.className = 'network-board network-board--full fg-stage';
|
||||
|
||||
const profileCardCache = new Map();
|
||||
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
|
||||
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
|
||||
let redrawEdges = () => {};
|
||||
let engine = null;
|
||||
let sheetEl = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
|
||||
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'];
|
||||
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);
|
||||
});
|
||||
if (engine) engine.setFilter(FILTERS[key].pred);
|
||||
}
|
||||
|
||||
function profileInfoRoute(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
@ -692,62 +418,69 @@ export function render({ navigate, route }) {
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
}
|
||||
|
||||
function getProfileCardCached(login) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
const key = normKey(cleanLogin);
|
||||
if (!cleanLogin) return Promise.resolve(null);
|
||||
if (!profileCardCache.has(key)) {
|
||||
profileCardCache.set(key, loadUserProfileCard(cleanLogin).catch(() => null));
|
||||
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
|
||||
function showNodeSheet(node) {
|
||||
if (!sheetEl) {
|
||||
sheetEl = document.createElement('div');
|
||||
sheetEl.className = 'fg-sheet';
|
||||
stage.append(sheetEl);
|
||||
}
|
||||
return profileCardCache.get(key);
|
||||
const login = normalizeLogin(node.login);
|
||||
const shineBadge = node.shining ? '<span class="fg-sheet-badge">сияющий</span>' : '';
|
||||
sheetEl.innerHTML = `
|
||||
<button class="fg-sheet-close" type="button" data-act="close" aria-label="Закрыть">✕</button>
|
||||
<div class="fg-sheet-body">
|
||||
<div class="fg-sheet-title">${escapeHtml(login)} ${shineBadge}</div>
|
||||
<div class="fg-sheet-rel">${escapeHtml(relationLabelRu(node.relationType))}</div>
|
||||
</div>
|
||||
<div class="fg-sheet-actions">
|
||||
<button class="ghost-btn" type="button" data-act="profile">Профиль</button>
|
||||
<button class="primary-btn" type="button" data-act="write">Написать</button>
|
||||
</div>
|
||||
`;
|
||||
sheetEl.classList.add('is-open');
|
||||
sheetEl.onclick = (e) => {
|
||||
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'close') hideNodeSheet();
|
||||
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
|
||||
else if (act === 'write') navigate(`chat-view/${encodeURIComponent(login)}`);
|
||||
};
|
||||
}
|
||||
|
||||
async function hydrateNodeProfiles(layout, nodeElements, requestId) {
|
||||
const uniqueNodes = [];
|
||||
const seen = new Set();
|
||||
layout.nodes.forEach((nodeModel) => {
|
||||
const key = normKey(nodeModel.login);
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
uniqueNodes.push(nodeModel);
|
||||
});
|
||||
|
||||
const cards = await Promise.all(uniqueNodes.map((nodeModel) => getProfileCardCached(nodeModel.login)));
|
||||
if (requestId !== loadSeq) return;
|
||||
|
||||
const cardByKey = new Map();
|
||||
cards.forEach((card) => {
|
||||
const login = normalizeLogin(card?.login);
|
||||
if (!login) return;
|
||||
cardByKey.set(normKey(login), card);
|
||||
});
|
||||
|
||||
layout.nodes.forEach((nodeModel) => {
|
||||
const node = nodeElements.get(nodeModel.id);
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const card = cardByKey.get(normKey(nodeModel.login));
|
||||
const cardGender = normalizeGender(card?.gender);
|
||||
applyNodeText(node, {
|
||||
login: nodeModel.login,
|
||||
firstName: card?.firstName || '',
|
||||
lastName: card?.lastName || '',
|
||||
role: nodeModel.relation || 'friend',
|
||||
gender: nodeModel.gender === GENDER_UNKNOWN ? cardGender : nodeModel.gender,
|
||||
mark: nodeModel.mark || null,
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => redrawEdges());
|
||||
function hideNodeSheet() {
|
||||
if (sheetEl) sheetEl.classList.remove('is-open');
|
||||
}
|
||||
|
||||
function bindNodeInteraction(node, nodeModel) {
|
||||
node.addEventListener('click', () => {
|
||||
if (nodeModel.isCenter) {
|
||||
const routeTo = profileInfoRoute(nodeModel.login);
|
||||
function ensureEngine(model) {
|
||||
if (engine) {
|
||||
engine.setModel(model);
|
||||
return;
|
||||
}
|
||||
engine = createForceGraph({
|
||||
stage: board,
|
||||
model,
|
||||
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
|
||||
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
|
||||
// тап по центру — полноценный профиль
|
||||
onCenterTap: (node) => {
|
||||
const routeTo = profileInfoRoute(node.login);
|
||||
if (routeTo) navigate(routeTo);
|
||||
return;
|
||||
}
|
||||
void load(nodeModel.login, { pushHistory: true });
|
||||
},
|
||||
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
||||
onNodeLongPress: (node, point) => {
|
||||
const login = normalizeLogin(node.login);
|
||||
openNodeMenu({
|
||||
login,
|
||||
relationType: node.relationType,
|
||||
point,
|
||||
actions: [
|
||||
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(login); if (r) navigate(r); } },
|
||||
{ label: 'Написать', onClick: () => navigate(`chat-view/${encodeURIComponent(login)}`) },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -765,34 +498,12 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||
|
||||
const model = buildGraphModel(graph, targetCenter);
|
||||
const layout = layoutNodes(model);
|
||||
const graphModel = buildGraphModel(graph, targetCenter);
|
||||
const engineModel = engineModelFromGraphModel(graphModel);
|
||||
ensureEngine(engineModel);
|
||||
// сохраняем выбранный фильтр при перестроении графа (центрирование/переход)
|
||||
if (engine && activeFilter !== 'all') engine.setFilter(FILTERS[activeFilter].pred);
|
||||
|
||||
board.innerHTML = '';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'network-svg');
|
||||
board.append(svg);
|
||||
|
||||
const nodeElements = new Map();
|
||||
layout.nodes.forEach((nodeModel) => {
|
||||
const node = buildNodeElement({
|
||||
login: nodeModel.login,
|
||||
kind: nodeModel.kind,
|
||||
isCenter: nodeModel.isCenter,
|
||||
role: nodeModel.relation || 'friend',
|
||||
gender: nodeModel.gender,
|
||||
mark: nodeModel.mark,
|
||||
});
|
||||
node.style.left = `${nodeModel.x}%`;
|
||||
node.style.top = `${nodeModel.y}%`;
|
||||
board.append(node);
|
||||
nodeElements.set(nodeModel.id, node);
|
||||
bindNodeInteraction(node, nodeModel);
|
||||
});
|
||||
|
||||
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
|
||||
requestAnimationFrame(() => redrawEdges());
|
||||
void hydrateNodeProfiles(layout, nodeElements, requestId);
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
@ -823,18 +534,10 @@ export function render({ navigate, route }) {
|
||||
const backBtnEl = header.querySelector('.header-left .icon-btn');
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
const onResize = () => redrawEdges();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
let observer = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(() => redrawEdges());
|
||||
observer.observe(board);
|
||||
}
|
||||
|
||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||
screen.cleanup = () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (observer) observer.disconnect();
|
||||
if (engine) engine.destroy();
|
||||
engine = null;
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
@ -857,8 +560,21 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
setBackButtonState(backBtnEl);
|
||||
|
||||
// Панель фильтров слоёв (оверлей под шапкой)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'fg-filter-bar';
|
||||
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);
|
||||
});
|
||||
|
||||
header.classList.add('network-header-overlay');
|
||||
stage.append(board, header);
|
||||
stage.append(board, header, filterBar);
|
||||
screen.append(stage);
|
||||
return screen;
|
||||
}
|
||||
|
||||
72
shine-UI/js/pages/network/adapter.js
Normal file
72
shine-UI/js/pages/network/adapter.js
Normal file
@ -0,0 +1,72 @@
|
||||
// Адаптер реальных данных → нейтральная модель движка force-графа.
|
||||
//
|
||||
// Источник — результат buildGraphModel() из network-view.js (он уже нормализует ответ
|
||||
// authService.getUserConnectionsGraph в роли/направления/метки). Здесь только конвертация
|
||||
// в форму, которую понимает движок ({ focusId, nodes[] }). Сервер не трогаем — это чтение.
|
||||
|
||||
function normLogin(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
const FAMILY_ROLES = ['parent', 'child', 'spouse', 'sibling'];
|
||||
|
||||
function relationTypeFromRole(role) {
|
||||
if (FAMILY_ROLES.includes(role)) return 'family';
|
||||
if (role === 'friend') return 'friend';
|
||||
return 'contact';
|
||||
}
|
||||
|
||||
// Сила связи (0..1) → радиус орбиты в движке. Реальный API пока не отдаёт численную силу,
|
||||
// поэтому выводим её эвристически из роли и направления связи:
|
||||
// - родственники держим ближе всего, контакты — дальше всего;
|
||||
// - взаимные дружеские связи ближе односторонних.
|
||||
function deriveStrength(relation) {
|
||||
if (relation.isRelative) return 0.9;
|
||||
if (relation.role === 'contact') return 0.4;
|
||||
if (relation.forward && relation.backward) return 0.8;
|
||||
return 0.55;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{centerLogin:string, centerMark:object|null, relations:Array}} graphModel
|
||||
* @returns {{focusId:string, nodes:Array}}
|
||||
*/
|
||||
export function engineModelFromGraphModel(graphModel) {
|
||||
const focusLogin = normLogin(graphModel?.centerLogin);
|
||||
const centerMark = graphModel?.centerMark || null;
|
||||
|
||||
const focusNode = {
|
||||
id: focusLogin,
|
||||
login: focusLogin,
|
||||
name: '',
|
||||
avatar: centerMark?.avatar || null,
|
||||
relationType: 'self',
|
||||
strength: 1,
|
||||
shining: Boolean(centerMark?.shine),
|
||||
tier: 1,
|
||||
};
|
||||
|
||||
const relations = Array.isArray(graphModel?.relations) ? graphModel.relations : [];
|
||||
const seen = new Set();
|
||||
const nodes = relations
|
||||
.map((r) => {
|
||||
const login = normLogin(r?.login);
|
||||
const key = login.toLowerCase();
|
||||
// исключаем сам фокус (не должно быть линии на собственный ник) и дубли
|
||||
if (!login || key === focusLogin.toLowerCase() || seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
return {
|
||||
id: login,
|
||||
login,
|
||||
name: '',
|
||||
avatar: r?.mark?.avatar || null,
|
||||
relationType: relationTypeFromRole(r?.role),
|
||||
strength: deriveStrength(r || {}),
|
||||
shining: Boolean(r?.mark?.shine),
|
||||
tier: 1,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return { focusId: focusLogin, nodes: [focusNode, ...nodes] };
|
||||
}
|
||||
900
shine-UI/js/pages/network/force-graph.js
Normal file
900
shine-UI/js/pages/network/force-graph.js
Normal file
@ -0,0 +1,900 @@
|
||||
// Движок интерактивной карты связей (force-directed graph).
|
||||
//
|
||||
// Что делает:
|
||||
// - держит «мир» (бесконечный холст), по которому можно панорамировать свайпом;
|
||||
// - раскладывает фокусный узел в центре, остальные — по орбите (радиус зависит от силы связи);
|
||||
// - по тапу на периферийный узел плавно стягивает его в центр (новый фокус), старый уходит на орбиту;
|
||||
// - рисует рёбра аналитически (без чтения DOM в цикле) — дёшево для 60 FPS.
|
||||
//
|
||||
// Критичные требования (см. план):
|
||||
// 1) Kill-switch физики: цикл rAF останавливается, когда кинетическая энергия падает ниже порога;
|
||||
// просыпается только при взаимодействии (pan / tap / recenter). Батарея не «выжирается».
|
||||
// 2) Конфликт жестов: любой pan мгновенно прерывает твин центрирования и отдаёт управление пальцу.
|
||||
//
|
||||
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё):
|
||||
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
||||
|
||||
import { renderUserAvatar } from '../../components/avatar-image.js';
|
||||
|
||||
// --- Параметры физики и анимации ---------------------------------------------
|
||||
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
||||
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
|
||||
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
|
||||
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
|
||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||||
const FRICTION = 0.82; // базовое затухание скорости (свободное покачивание)
|
||||
const FRICTION_BOOST = 0.92; // максимальная вязкость в первые ~600мс после перестроения (гасит «взрыв»)
|
||||
const BOOST_FRAMES = 36; // длительность затухающего boost'а вязкости (~600мс @60fps)
|
||||
const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| для жёсткой заморозки графа
|
||||
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
||||
const EDGE_LERP = 0.25; // догон концов линии за узлом за кадр (эффект натянутой резинки)
|
||||
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
||||
const TWEEN_MS = 560; // длительность анимации центрирования
|
||||
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
|
||||
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
||||
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
||||
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
|
||||
const LONGPRESS_MS = 480; // порог долгого нажатия
|
||||
const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки
|
||||
|
||||
const RELATION_COLORS = {
|
||||
family: 'rgba(255, 159, 94, 0.92)',
|
||||
friend: 'rgba(120, 179, 255, 0.9)',
|
||||
business: 'rgba(190, 150, 255, 0.9)',
|
||||
contact: 'rgba(170, 190, 220, 0.7)',
|
||||
};
|
||||
|
||||
function easeOutCubic(t) {
|
||||
const x = 1 - t;
|
||||
return 1 - x * x * x;
|
||||
}
|
||||
|
||||
function relationColor(relationType) {
|
||||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||||
}
|
||||
|
||||
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
||||
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
||||
function spreadAngle(index, total) {
|
||||
if (total <= 0) return 0;
|
||||
return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
|
||||
}
|
||||
|
||||
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
|
||||
// а не идеальным кругом. Без Math.random: одинаковый узел всегда смещён одинаково.
|
||||
function hash01(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) % 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт движок графа внутри переданного контейнера-сцены.
|
||||
* @param {Object} opts
|
||||
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
|
||||
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
|
||||
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
|
||||
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
|
||||
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
||||
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
||||
*/
|
||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) {
|
||||
// Слои DOM
|
||||
const edgesSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
edgesSvg.setAttribute('class', 'fg-edges');
|
||||
const world = document.createElement('div');
|
||||
world.className = 'fg-world';
|
||||
// «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется,
|
||||
// когда под него попадает узел (визуальная зона фокуса при свободном панорамировании).
|
||||
const reticle = document.createElement('div');
|
||||
reticle.className = 'fg-reticle';
|
||||
stage.append(edgesSvg, world, reticle);
|
||||
|
||||
// Состояние камеры (панорамирование)
|
||||
let camX = 0;
|
||||
let camY = 0;
|
||||
let viewW = stage.clientWidth || window.innerWidth;
|
||||
let viewH = stage.clientHeight || window.innerHeight;
|
||||
let centerX = viewW / 2;
|
||||
let centerY = viewH / 2;
|
||||
|
||||
// Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius }
|
||||
let nodes = [];
|
||||
let focusId = '';
|
||||
|
||||
// Управление циклом rAF
|
||||
let rafId = 0;
|
||||
let dragging = false;
|
||||
|
||||
// Твин центрирования
|
||||
let tween = null; // { startTs, from: Map(id->{x,y,scale}), to: Map(id->{x,y,scale}), camFrom, camTo }
|
||||
|
||||
// Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла)
|
||||
let pendingFocusOrigin = null;
|
||||
|
||||
// Прогресс «прорастания» линий 0→1 (1 = полностью вычерчены)
|
||||
let edgeGrowth = 1;
|
||||
|
||||
// Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра.
|
||||
let boost = 0;
|
||||
let frictionNow = FRICTION;
|
||||
let chargeNow = CHARGE;
|
||||
|
||||
// Инерция панорамирования (kinematic panning)
|
||||
let panVelX = 0;
|
||||
let panVelY = 0;
|
||||
|
||||
// --- Построение модели -----------------------------------------------------
|
||||
// Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга:
|
||||
// фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки.
|
||||
function computeSpecs(srcModel) {
|
||||
const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : [];
|
||||
const fId = String(srcModel?.focusId || (list[0] && list[0].id) || '');
|
||||
const peers = list
|
||||
.filter((n) => String(n.id) !== fId)
|
||||
.sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0));
|
||||
const dotCount = Math.max(0, peers.length - MAX_FULL_NODES);
|
||||
if (dotCount > 0) {
|
||||
console.info(`[force-graph] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`);
|
||||
}
|
||||
const specs = [];
|
||||
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
|
||||
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
|
||||
peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES }));
|
||||
return { focusId: fId, specs };
|
||||
}
|
||||
|
||||
function buildNodes(srcModel) {
|
||||
const { focusId: fId, specs } = computeSpecs(srcModel);
|
||||
focusId = fId;
|
||||
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly));
|
||||
}
|
||||
|
||||
function makeNodeState(src, isFocus, index, total, dotOnly = false) {
|
||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||
const tier = Number(src.tier) || 1;
|
||||
// органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад)
|
||||
const jr = (hash01(src.id) - 0.5) * 18;
|
||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
||||
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
||||
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
|
||||
const scale = isFocus ? FOCUS_SCALE : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE);
|
||||
// целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру —
|
||||
// узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует.
|
||||
const tx = isFocus ? 0 : Math.cos(angle) * targetR;
|
||||
const ty = isFocus ? 0 : Math.sin(angle) * targetR;
|
||||
const el = buildNodeElement(src, isFocus, tier, dotOnly);
|
||||
world.append(el);
|
||||
return {
|
||||
...src,
|
||||
isFocus,
|
||||
tier,
|
||||
dotOnly,
|
||||
strength,
|
||||
targetR,
|
||||
angle,
|
||||
tx,
|
||||
ty,
|
||||
x: tx * INTRO_FACTOR,
|
||||
y: ty * INTRO_FACTOR,
|
||||
lerpX: tx * INTRO_FACTOR,
|
||||
lerpY: ty * INTRO_FACTOR,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
scale,
|
||||
targetScale: scale,
|
||||
hidden: false,
|
||||
opacity: 1,
|
||||
targetOpacity: 1,
|
||||
bloom: false,
|
||||
el,
|
||||
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
|
||||
if (dotOnly) {
|
||||
el.className = [
|
||||
'fg-node', 'fg-dot',
|
||||
src.shining ? 'is-shine' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
].filter(Boolean).join(' ');
|
||||
el.dataset.nodeId = String(src.id);
|
||||
el.title = src.name || src.login || '';
|
||||
return el;
|
||||
}
|
||||
el.className = [
|
||||
'fg-node',
|
||||
isFocus ? 'is-focus' : '',
|
||||
src.shining ? 'is-shine' : '',
|
||||
`is-${src.relationType || 'contact'}`,
|
||||
tier >= 2 ? 'is-secondary' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
el.dataset.nodeId = String(src.id);
|
||||
|
||||
const avatar = renderUserAvatar({
|
||||
login: src.login || src.name || String(src.id),
|
||||
firstName: src.name || '',
|
||||
avatar: src.avatar || null,
|
||||
size: 'node',
|
||||
title: src.name || src.login || '',
|
||||
});
|
||||
el.append(avatar);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'fg-node-label';
|
||||
label.textContent = src.name || src.login || '';
|
||||
el.append(label);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель.
|
||||
function updateNodeRole(node, spec) {
|
||||
const src = spec.src;
|
||||
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
|
||||
const tier = Number(src.tier) || 1;
|
||||
node.isFocus = spec.isFocus;
|
||||
node.tier = tier;
|
||||
node.dotOnly = spec.dotOnly;
|
||||
node.strength = strength;
|
||||
node.relationType = src.relationType;
|
||||
node.shining = Boolean(src.shining);
|
||||
const jr = (hash01(src.id) - 0.5) * 18;
|
||||
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
|
||||
node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
|
||||
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
|
||||
node.tx = Math.cos(node.angle) * node.targetR;
|
||||
node.ty = Math.sin(node.angle) * node.targetR;
|
||||
node.targetScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
||||
node.targetOpacity = 1;
|
||||
node.hidden = false;
|
||||
node.bloom = false;
|
||||
node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26));
|
||||
// обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM
|
||||
node.el.className = spec.dotOnly
|
||||
? ['fg-node', 'fg-dot', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
|
||||
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier >= 2 ? 'is-secondary' : ''].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// --- Рендер ----------------------------------------------------------------
|
||||
function applyWorldTransform() {
|
||||
world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`;
|
||||
}
|
||||
|
||||
function renderNodes() {
|
||||
for (const n of nodes) {
|
||||
n.el.style.transform =
|
||||
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`;
|
||||
n.el.style.opacity = String(n.opacity);
|
||||
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEdges() {
|
||||
const focus = nodes.find((n) => n.id === focusId);
|
||||
if (!focus) {
|
||||
edgesSvg.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость
|
||||
const tx = (n) => centerX + camX + n.x;
|
||||
const ty = (n) => centerY + camY + n.y;
|
||||
|
||||
const fx = tx(focus);
|
||||
const fy = ty(focus);
|
||||
const fr = focus.dotRadius * focus.scale + 4;
|
||||
|
||||
const focusLogin = String(focus.login || '').toLowerCase();
|
||||
const parts = [];
|
||||
for (const n of nodes) {
|
||||
if (n === focus) continue;
|
||||
if (n.hidden) continue;
|
||||
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
||||
const nx = tx(n);
|
||||
const ny = ty(n);
|
||||
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
|
||||
|
||||
const dx = nx - fx;
|
||||
const dy = ny - fy;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const nr = n.dotRadius * n.scale + 4;
|
||||
// концы линии — у краёв кружков (по истинной позиции)
|
||||
const x1 = fx + ux * fr;
|
||||
const y1 = fy + uy * fr;
|
||||
const x2 = nx - ux * nr;
|
||||
const y2 = ny - uy * nr;
|
||||
// контрольная точка кривой Безье: постоянный лёгкий изгиб (провисание) перпендикулярно
|
||||
// линии + динамика от запаздывания (при движении узла нить выгибается сильнее)
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
const segLen0 = Math.hypot(x2 - x1, y2 - y1);
|
||||
// изгиб строго перпендикулярный: заметная постоянная дуга (≈7–22px) +
|
||||
// динамика от скорости узла → при движении нить выгибается, как натянутая резина
|
||||
const speed = Math.hypot(n.vx, n.vy);
|
||||
const bow = Math.max(7, Math.min(22, segLen0 * 0.13)) + Math.min(16, speed * 1.2);
|
||||
const cpx = mx + (-uy) * bow * 2; // CP даёт середину Q-кривой = M + perp*bow
|
||||
const cpy = my + ux * bow * 2;
|
||||
// минимализм: тонкие (1.3–1.8px), полупрозрачные линии — без «энергетических лучей»
|
||||
const w = 1.3 + n.strength * 0.5;
|
||||
// прорастание: длину пути приближаем хордой, dash-offset → 0
|
||||
let dash = '';
|
||||
if (edgeGrowth < 1) {
|
||||
const segLen = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy)) || 1;
|
||||
dash = ` stroke-dasharray="${segLen.toFixed(1)}" stroke-dashoffset="${(segLen * (1 - edgeGrowth)).toFixed(1)}"`;
|
||||
}
|
||||
parts.push(
|
||||
`<path d="M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}" `
|
||||
+ `fill="none" stroke="${relationColor(n.relationType)}" stroke-opacity="0.42" stroke-width="${w.toFixed(2)}" stroke-linecap="round"${dash} />`
|
||||
);
|
||||
}
|
||||
edgesSvg.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function updateReticle() {
|
||||
// ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0)
|
||||
let best = Infinity;
|
||||
for (const n of nodes) {
|
||||
if (n.hidden) continue;
|
||||
const d = Math.hypot(camX + n.x, camY + n.y);
|
||||
if (d < best) best = d;
|
||||
}
|
||||
reticle.classList.toggle('is-locked', best < 46);
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderNodes();
|
||||
renderEdges();
|
||||
updateReticle();
|
||||
}
|
||||
|
||||
// --- Физика (пружины + отталкивание) ---------------------------------------
|
||||
// Фокус не «пинится» жёстко, а влетает к центру пружиной (упругая стабилизация).
|
||||
// Периферия держится радиальной пружиной на орбите и расталкивается силой charge —
|
||||
// получается органичная плавающая структура, а не жёсткий круг.
|
||||
function stepPhysics() {
|
||||
let totalV = 0;
|
||||
for (const n of nodes) {
|
||||
if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает
|
||||
let ax = 0;
|
||||
let ay = 0;
|
||||
|
||||
if (n.isFocus) {
|
||||
// пружина к центру: быстрый влёт + лёгкий отскок (фокус сам не отталкивается)
|
||||
ax += K_FOCUS * (0 - n.x);
|
||||
ay += K_FOCUS * (0 - n.y);
|
||||
} else {
|
||||
// радиальная пружина к целевому радиусу орбиты
|
||||
const d = Math.hypot(n.x, n.y) || 0.001;
|
||||
const ux = n.x / d;
|
||||
const uy = n.y / d;
|
||||
const fr = K_RADIAL * (n.targetR - d);
|
||||
ax += fr * ux;
|
||||
ay += fr * uy;
|
||||
// отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре)
|
||||
for (const m of nodes) {
|
||||
if (m === n || m.hidden) continue;
|
||||
const dx = n.x - m.x;
|
||||
const dy = n.y - m.y;
|
||||
let dist2 = dx * dx + dy * dy;
|
||||
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
||||
const dist = Math.sqrt(dist2);
|
||||
const f = chargeNow / dist2;
|
||||
ax += (dx / dist) * f;
|
||||
ay += (dy / dist) * f;
|
||||
}
|
||||
}
|
||||
|
||||
n.vx = (n.vx + ax) * frictionNow;
|
||||
n.vy = (n.vy + ay) * frictionNow;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
totalV += Math.abs(n.vx) + Math.abs(n.vy);
|
||||
}
|
||||
return totalV;
|
||||
}
|
||||
|
||||
// Концы линий догоняют узлы с запаздыванием (эффект резинки): lerp-позиция тянется за реальной.
|
||||
function advanceLerp() {
|
||||
for (const n of nodes) {
|
||||
n.lerpX += (n.x - n.lerpX) * EDGE_LERP;
|
||||
n.lerpY += (n.y - n.lerpY) * EDGE_LERP;
|
||||
}
|
||||
}
|
||||
|
||||
// Плавное приближение масштаба/прозрачности к целям (bloom новых, рост/уменьшение при смене роли).
|
||||
function advanceVisual() {
|
||||
for (const n of nodes) {
|
||||
n.scale += (n.targetScale - n.scale) * 0.2;
|
||||
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
// Не «успокоились» ли ещё визуальные параметры (для условия заморозки).
|
||||
function visualSettling() {
|
||||
for (const n of nodes) {
|
||||
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Твин центрирования -----------------------------------------------------
|
||||
function startRecenterTween(newFocusId) {
|
||||
const target = nodes.find((n) => String(n.id) === String(newFocusId));
|
||||
if (!target || target.isFocus) return;
|
||||
|
||||
focusId = String(newFocusId);
|
||||
// пересчёт ролей: новый фокус — в центр, остальные — на орбиту (включая старый фокус)
|
||||
const peers = nodes.filter((n) => String(n.id) !== focusId);
|
||||
const from = new Map();
|
||||
const to = new Map();
|
||||
nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale }));
|
||||
|
||||
nodes.forEach((n) => {
|
||||
n.isFocus = String(n.id) === focusId;
|
||||
n.el.classList.toggle('is-focus', n.isFocus);
|
||||
});
|
||||
|
||||
target.targetR = 0;
|
||||
target.tx = 0;
|
||||
target.ty = 0;
|
||||
target.vx = 0;
|
||||
target.vy = 0;
|
||||
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
|
||||
|
||||
peers.forEach((n, i) => {
|
||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
||||
n.angle = spreadAngle(i, peers.length);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
const tx = Math.cos(n.angle) * n.targetR;
|
||||
const ty = Math.sin(n.angle) * n.targetR;
|
||||
n.tx = tx; // обновляем целевую точку, иначе физика после твина утянет узел назад
|
||||
n.ty = ty;
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
to.set(n.id, { x: tx, y: ty, scale: sc });
|
||||
});
|
||||
|
||||
tween = {
|
||||
startTs: 0,
|
||||
from,
|
||||
to,
|
||||
camFrom: { x: camX, y: camY },
|
||||
camTo: { x: 0, y: 0 },
|
||||
};
|
||||
wake();
|
||||
}
|
||||
|
||||
function stepTween(ts) {
|
||||
if (!tween.startTs) tween.startTs = ts;
|
||||
const raw = Math.min(1, (ts - tween.startTs) / TWEEN_MS);
|
||||
const t = easeOutCubic(raw);
|
||||
for (const n of nodes) {
|
||||
const a = tween.from.get(n.id);
|
||||
const b = tween.to.get(n.id);
|
||||
if (!a || !b) continue;
|
||||
n.x = a.x + (b.x - a.x) * t;
|
||||
n.y = a.y + (b.y - a.y) * t;
|
||||
n.scale = a.scale + (b.scale - a.scale) * t;
|
||||
const ao = a.opacity ?? 1;
|
||||
const bo = b.opacity ?? 1;
|
||||
n.opacity = ao + (bo - ao) * t;
|
||||
}
|
||||
camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * t;
|
||||
camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * t;
|
||||
applyWorldTransform();
|
||||
if (raw >= 1) {
|
||||
tween = null; // твин завершён
|
||||
// синхронизируем цели визуала с текущими, чтобы advanceVisual не «откатил» (важно для фильтра)
|
||||
for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; }
|
||||
}
|
||||
}
|
||||
|
||||
// Прерывание твина жестом (требование «конфликт жестов»): фиксируем текущие позиции и отдаём пальцу.
|
||||
function cancelTween() {
|
||||
if (!tween) return;
|
||||
tween = null;
|
||||
for (const n of nodes) { n.vx = 0; n.vy = 0; }
|
||||
}
|
||||
|
||||
// Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
|
||||
// Видимые перераспределяются по орбите, скрытые плавно гаснут (scale↓ + opacity→0).
|
||||
function setFilter(predicate) {
|
||||
const pred = typeof predicate === 'function' ? predicate : () => true;
|
||||
const from = new Map();
|
||||
const to = new Map();
|
||||
nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale, opacity: n.opacity }));
|
||||
|
||||
const visiblePeers = [];
|
||||
nodes.forEach((n) => {
|
||||
if (n.isFocus) { n.hidden = false; return; }
|
||||
n.hidden = !pred(n);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
if (!n.hidden) visiblePeers.push(n);
|
||||
});
|
||||
|
||||
const focus = nodes.find((n) => n.isFocus);
|
||||
if (focus) to.set(focus.id, { x: 0, y: 0, scale: FOCUS_SCALE, opacity: 1 });
|
||||
|
||||
visiblePeers.forEach((n, i) => {
|
||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
||||
n.angle = spreadAngle(i, visiblePeers.length);
|
||||
n.tx = Math.cos(n.angle) * n.targetR;
|
||||
n.ty = Math.sin(n.angle) * n.targetR;
|
||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||
to.set(n.id, { x: n.tx, y: n.ty, scale: sc, opacity: 1 });
|
||||
});
|
||||
|
||||
nodes.forEach((n) => {
|
||||
if (n.isFocus || !n.hidden) return;
|
||||
// скрытые: подтягиваем к центру и гасим
|
||||
to.set(n.id, { x: n.x * 0.35, y: n.y * 0.35, scale: 0.2, opacity: 0 });
|
||||
});
|
||||
|
||||
// фильтр не двигает камеру (в отличие от центрирования)
|
||||
tween = { startTs: 0, from, to, camFrom: { x: camX, y: camY }, camTo: { x: camX, y: camY } };
|
||||
wake();
|
||||
}
|
||||
|
||||
// Жёсткая заморозка: гасим скорости, округляем координаты до целых пикселей,
|
||||
// подтягиваем lerp и НЕ перезапускаем цикл — граф замирает намертво (без «треска»).
|
||||
function freezeGraph() {
|
||||
for (const n of nodes) {
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
n.x = Math.round(n.x);
|
||||
n.y = Math.round(n.y);
|
||||
n.lerpX = n.x;
|
||||
n.lerpY = n.y;
|
||||
n.scale = n.targetScale;
|
||||
n.opacity = n.targetOpacity;
|
||||
}
|
||||
renderAll(); // финальный кадр на целых координатах
|
||||
}
|
||||
|
||||
// --- Цикл с kill-switch + инерция + заморозка ------------------------------
|
||||
function tick(ts) {
|
||||
rafId = 0;
|
||||
|
||||
// инерция панорамирования (kinematic): камера докатывается с трением
|
||||
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
||||
if (panActive) {
|
||||
camX += panVelX;
|
||||
camY += panVelY;
|
||||
panVelX *= PAN_FRICTION;
|
||||
panVelY *= PAN_FRICTION;
|
||||
applyWorldTransform();
|
||||
} else {
|
||||
panVelX = 0;
|
||||
panVelY = 0;
|
||||
}
|
||||
|
||||
if (edgeGrowth < 1) edgeGrowth = Math.min(1, edgeGrowth + 0.07); // прорастание линий ~15 кадров
|
||||
|
||||
// динамическая вязкость: первые ~400мс после перестроения трение выше (0.90→0.82),
|
||||
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту мягко
|
||||
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
|
||||
chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost);
|
||||
if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES);
|
||||
|
||||
let totalV = 0;
|
||||
if (tween) {
|
||||
stepTween(ts);
|
||||
} else {
|
||||
totalV = stepPhysics();
|
||||
advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity
|
||||
}
|
||||
|
||||
advanceLerp();
|
||||
renderAll();
|
||||
|
||||
const lerpSettling = nodes.some((n) => Math.abs(n.x - n.lerpX) + Math.abs(n.y - n.lerpY) > 0.5);
|
||||
if (tween || dragging || panActive || edgeGrowth < 1 || boost > 0 || totalV > SLEEP_V || lerpSettling || visualSettling()) {
|
||||
schedule();
|
||||
} else {
|
||||
freezeGraph(); // система успокоилась — замираем
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!rafId) rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function wake() {
|
||||
schedule();
|
||||
}
|
||||
|
||||
// --- Жесты (pan / tap / longpress) -----------------------------------------
|
||||
let pointerId = null;
|
||||
let downX = 0;
|
||||
let downY = 0;
|
||||
let camStartX = 0;
|
||||
let camStartY = 0;
|
||||
let moved = false;
|
||||
let downNodeEl = null;
|
||||
let longTimer = 0;
|
||||
let longFired = false;
|
||||
|
||||
function nodeFromEvent(ev) {
|
||||
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||
if (!el) return null;
|
||||
const id = el.dataset.nodeId;
|
||||
return nodes.find((n) => String(n.id) === String(id)) || null;
|
||||
}
|
||||
|
||||
function onPointerDown(ev) {
|
||||
if (pointerId !== null) return;
|
||||
pointerId = ev.pointerId;
|
||||
panVelX = 0; // новое касание мгновенно прерывает инерцию
|
||||
panVelY = 0;
|
||||
try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ }
|
||||
downX = ev.clientX;
|
||||
downY = ev.clientY;
|
||||
camStartX = camX;
|
||||
camStartY = camY;
|
||||
moved = false;
|
||||
longFired = false;
|
||||
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||
const downNode = nodeFromEvent(ev);
|
||||
if (downNode && typeof onNodeLongPress === 'function') {
|
||||
longTimer = window.setTimeout(() => {
|
||||
if (moved) return;
|
||||
longFired = true;
|
||||
const rect = downNode.el.getBoundingClientRect();
|
||||
// координаты для меню берём из экранного rect узла (меню рендерится вне масштабируемого мира)
|
||||
onNodeLongPress(downNode, { x: rect.left + rect.width / 2, y: rect.top, rect });
|
||||
}, LONGPRESS_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(ev) {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
const dx = ev.clientX - downX;
|
||||
const dy = ev.clientY - downY;
|
||||
if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
moved = true;
|
||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||
cancelTween(); // жест прерывает анимацию центрирования
|
||||
dragging = true;
|
||||
}
|
||||
if (moved) {
|
||||
const newCamX = camStartX + dx;
|
||||
const newCamY = camStartY + dy;
|
||||
panVelX = newCamX - camX; // мгновенная скорость свайпа (для инерции после отпускания)
|
||||
panVelY = newCamY - camY;
|
||||
camX = newCamX;
|
||||
camY = newCamY;
|
||||
applyWorldTransform();
|
||||
renderEdges(); // рёбра следуют за камерой синхронно (дёшево)
|
||||
updateReticle();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(ev) {
|
||||
if (ev.pointerId !== pointerId) return;
|
||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
|
||||
const wasMoved = moved;
|
||||
const wasLong = longFired;
|
||||
pointerId = null;
|
||||
dragging = false;
|
||||
|
||||
if (wasMoved || wasLong) {
|
||||
// после pan даём физике чуть устаканиться и уснуть
|
||||
if (wasMoved) wake();
|
||||
return;
|
||||
}
|
||||
// это был тап
|
||||
const tapNode = downNodeEl
|
||||
? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId))
|
||||
: null;
|
||||
if (!tapNode) return;
|
||||
if (tapNode.isFocus) {
|
||||
if (typeof onCenterTap === 'function') onCenterTap(tapNode);
|
||||
return;
|
||||
}
|
||||
if (typeof onNodeTap === 'function') {
|
||||
// запоминаем точку, из которой новый фокус влетит в центр; перестройку делает onNodeTap (setModel)
|
||||
pendingFocusOrigin = { id: String(tapNode.id), x: tapNode.x, y: tapNode.y };
|
||||
onNodeTap(tapNode);
|
||||
} else {
|
||||
// нет внешнего обработчика — внутреннее перецентрирование (фолбэк)
|
||||
startRecenterTween(tapNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
viewW = stage.clientWidth || window.innerWidth;
|
||||
viewH = stage.clientHeight || window.innerHeight;
|
||||
centerX = viewW / 2;
|
||||
centerY = viewH / 2;
|
||||
renderEdges();
|
||||
}
|
||||
|
||||
// --- Жизненный цикл узлов (diffing) ----------------------------------------
|
||||
// Ghost-слой = СНИМОК всего старого графа (узлы + линии) на полноэкранном слое.
|
||||
// Клон застывает СТРОГО НА МЕСТЕ (полноэкранный overlay → координаты не сбрасываются),
|
||||
// плавно уменьшается (scale 1→0.7) и растворяется (opacity 0.4→0) за 500мс — красивый
|
||||
// шлейф истории перехода, — после чего полностью удаляется из DOM.
|
||||
function spawnGhost() {
|
||||
if (!world.childElementCount) return;
|
||||
const ghost = document.createElement('div');
|
||||
ghost.className = 'fg-ghost-layer';
|
||||
const edgesClone = edgesSvg.cloneNode(true); // .fg-edges (inset:0) → линии совпадают по координатам
|
||||
edgesClone.style.opacity = ''; // снимаем возможный inline-fade, слой задаёт прозрачность сам
|
||||
const worldClone = world.cloneNode(true); // .fg-world (центр) → узлы на своих местах
|
||||
worldClone.style.transform = world.style.transform || '';
|
||||
ghost.append(edgesClone, worldClone);
|
||||
stage.insertBefore(ghost, edgesSvg); // позади живых слоёв
|
||||
void ghost.offsetWidth; // рефлоу для запуска CSS-перехода
|
||||
ghost.style.transform = 'scale(0.7)';
|
||||
ghost.style.opacity = '0';
|
||||
window.setTimeout(() => ghost.remove(), 800);
|
||||
}
|
||||
|
||||
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
|
||||
function pulseReticle() {
|
||||
reticle.classList.remove('is-pulse');
|
||||
void reticle.offsetWidth;
|
||||
reticle.classList.add('is-pulse');
|
||||
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
|
||||
}
|
||||
|
||||
// Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний:
|
||||
// • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита);
|
||||
// • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1);
|
||||
// • исчезнувший — уходит призраком в глубину и удаляется.
|
||||
function setModel(nextModel) {
|
||||
const { focusId: newFocusId, specs } = computeSpecs(nextModel);
|
||||
const newIds = new Set(specs.map((s) => s.id));
|
||||
const oldById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||
|
||||
// точка рождения новых узлов = текущая позиция нового фокуса (откуда он «исходит»)
|
||||
const focusOld = oldById.get(String(newFocusId));
|
||||
const originX = focusOld ? focusOld.x : (pendingFocusOrigin ? pendingFocusOrigin.x : 0);
|
||||
const originY = focusOld ? focusOld.y : (pendingFocusOrigin ? pendingFocusOrigin.y : 0);
|
||||
|
||||
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
||||
spawnGhost();
|
||||
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
||||
|
||||
focusId = String(newFocusId);
|
||||
edgeGrowth = 0; // линии к новым узлам прорастают из центра
|
||||
boost = 1; // включаем повышенную вязкость на ~400мс (гасим энергию разлёта)
|
||||
|
||||
const fresh = [];
|
||||
let bloomOrder = 0;
|
||||
nodes = specs.map((spec) => {
|
||||
const old = oldById.get(spec.id);
|
||||
if (old && old.dotOnly === spec.dotOnly) {
|
||||
updateNodeRole(old, spec); // непрерывность: тот же DOM, новая цель → перелёт пружиной
|
||||
return old;
|
||||
}
|
||||
if (old) old.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
|
||||
const node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
|
||||
// периферия «выстреливает» из центрального круга (0,0); смещение вдоль угла даёт направление силам
|
||||
const bx = Math.cos(node.angle) * 14;
|
||||
const by = Math.sin(node.angle) * 14;
|
||||
node.x = node.isFocus ? originX : bx;
|
||||
node.y = node.isFocus ? originY : by;
|
||||
node.lerpX = node.x; node.lerpY = node.y;
|
||||
node.scale = 0.01; node.opacity = 0; node.bloom = true;
|
||||
node.bloomScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (Number(spec.src.tier) >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
||||
if (node.isFocus) {
|
||||
node.targetScale = node.bloomScale; node.targetOpacity = 1; // фокус виден сразу (влетает)
|
||||
} else {
|
||||
// периферия: держим скрытой в центре и «выстреливаем» по очереди (каскад 40мс)
|
||||
node.hidden = true;
|
||||
node.targetScale = 0; node.targetOpacity = 0;
|
||||
node.bloomOrder = bloomOrder++;
|
||||
fresh.push(node);
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// новый фокус «влетает» из точки клика (если кликнули по периферийному узлу)
|
||||
if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) {
|
||||
const f = nodes.find((n) => n.isFocus);
|
||||
if (f && !focusOld) { f.x = pendingFocusOrigin.x; f.y = pendingFocusOrigin.y; f.lerpX = f.x; f.lerpY = f.y; }
|
||||
}
|
||||
pendingFocusOrigin = null;
|
||||
|
||||
// каскад: каждый новый узел освобождается из центра через order*40мс → волна
|
||||
fresh.forEach((node) => {
|
||||
window.setTimeout(() => {
|
||||
node.hidden = false;
|
||||
node.targetScale = node.bloomScale;
|
||||
node.targetOpacity = 1;
|
||||
wake();
|
||||
}, node.bloomOrder * 40);
|
||||
});
|
||||
|
||||
camX = 0;
|
||||
camY = 0;
|
||||
applyWorldTransform();
|
||||
renderAll();
|
||||
// линии: плавно проявляем (старые ушли с призраком)
|
||||
edgesSvg.style.opacity = '0';
|
||||
void edgesSvg.offsetWidth;
|
||||
edgesSvg.style.opacity = '1';
|
||||
pulseReticle();
|
||||
wake();
|
||||
}
|
||||
|
||||
stage.addEventListener('pointerdown', onPointerDown);
|
||||
stage.addEventListener('pointermove', onPointerMove);
|
||||
stage.addEventListener('pointerup', onPointerUp);
|
||||
stage.addEventListener('pointercancel', onPointerUp);
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
let ro = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(() => onResize());
|
||||
ro.observe(stage);
|
||||
}
|
||||
|
||||
setModel(model);
|
||||
|
||||
return {
|
||||
recenter: (id) => startRecenterTween(id),
|
||||
setModel,
|
||||
setFilter,
|
||||
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
|
||||
destroy() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
if (longTimer) window.clearTimeout(longTimer);
|
||||
stage.removeEventListener('pointerdown', onPointerDown);
|
||||
stage.removeEventListener('pointermove', onPointerMove);
|
||||
stage.removeEventListener('pointerup', onPointerUp);
|
||||
stage.removeEventListener('pointercancel', onPointerUp);
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (ro) ro.disconnect();
|
||||
edgesSvg.remove();
|
||||
world.remove();
|
||||
reticle.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует данные формы ТЗ (focusUser + connections[]) в нейтральную модель движка.
|
||||
* Используется на этапе мок-прототипа (Фаза 1).
|
||||
*/
|
||||
export function buildModelFromTz(tz) {
|
||||
const focus = tz?.focusUser || {};
|
||||
const focusNode = {
|
||||
id: String(focus.id || 'focus'),
|
||||
login: focus.login || focus.id || '',
|
||||
name: focus.name || '',
|
||||
avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null,
|
||||
relationType: 'self',
|
||||
strength: 1,
|
||||
shining: String(focus.status || '').toLowerCase() === 'shining',
|
||||
tier: 1,
|
||||
};
|
||||
const connections = Array.isArray(tz?.connections) ? tz.connections : [];
|
||||
const peerNodes = connections.map((c) => ({
|
||||
id: String(c.id),
|
||||
login: c.login || c.id || '',
|
||||
name: c.name || '',
|
||||
avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null,
|
||||
relationType: c.relationType || 'contact',
|
||||
strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5,
|
||||
shining: String(c.status || '').toLowerCase() === 'shining',
|
||||
tier: c.hasOwnConnections === false ? 1 : (c.tier || 1),
|
||||
}));
|
||||
return { focusId: focusNode.id, nodes: [focusNode, ...peerNodes] };
|
||||
}
|
||||
86
shine-UI/js/pages/network/lab.js
Normal file
86
shine-UI/js/pages/network/lab.js
Normal file
@ -0,0 +1,86 @@
|
||||
// Лабораторный режим карты связей (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';
|
||||
|
||||
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));
|
||||
|
||||
stage.append(header);
|
||||
screen.append(stage);
|
||||
|
||||
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
||||
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
||||
const graph = createForceGraph({
|
||||
stage,
|
||||
model,
|
||||
// тап по узлу — переключаем карту на сеть выбранного человека
|
||||
onNodeTap: (node) => { graph.setModel(buildModelFromTz(graphForLogin(node.login))); },
|
||||
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}`) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
screen.cleanup = () => {
|
||||
graph.destroy();
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
84
shine-UI/js/pages/network/node-menu.js
Normal file
84
shine-UI/js/pages/network/node-menu.js
Normal file
@ -0,0 +1,84 @@
|
||||
// Общее контекстное меню узла (долгое нажатие) для карты связей.
|
||||
// Рендерится в #modal-root (вне масштабируемого холста), позиционируется по экранному rect узла.
|
||||
// Используется и реальным путём (network-view.js), и лабораторией (lab.js).
|
||||
|
||||
const REL_RU = {
|
||||
family: 'Семья',
|
||||
friend: 'Друг',
|
||||
business: 'Бизнес',
|
||||
contact: 'Контакт',
|
||||
self: 'Вы',
|
||||
};
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function relationLabelRu(relationType) {
|
||||
return REL_RU[relationType] || 'Контакт';
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает контекстное меню узла.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.login - логин/идентификатор для заголовка
|
||||
* @param {string} opts.relationType - тип связи (для подписи)
|
||||
* @param {{x:number,y:number,rect?:DOMRect}} opts.point - экранная точка/rect узла
|
||||
* @param {Array<{label:string, onClick:Function, disabled?:boolean}>} opts.actions - пункты меню
|
||||
*/
|
||||
export function openNodeMenu({ login, relationType, point, actions = [] } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const itemsHtml = actions
|
||||
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="fg-menu-overlay" id="fg-menu-overlay">
|
||||
<div class="fg-menu" id="fg-menu" role="menu">
|
||||
<div class="fg-menu-head">
|
||||
<span class="fg-menu-login">${escapeHtml(login)}</span>
|
||||
<span class="fg-menu-rel">${escapeHtml(relationLabelRu(relationType))}</span>
|
||||
</div>
|
||||
${itemsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const overlay = root.querySelector('#fg-menu-overlay');
|
||||
const menu = root.querySelector('#fg-menu');
|
||||
if (!(overlay instanceof HTMLElement) || !(menu instanceof HTMLElement)) { root.innerHTML = ''; return; }
|
||||
|
||||
// позиционируем рядом с узлом по его экранному rect (синхронно, без rAF — он может троттлиться)
|
||||
const rect = point?.rect;
|
||||
const px = point?.x ?? (rect ? rect.left + rect.width / 2 : window.innerWidth / 2);
|
||||
const belowY = rect ? rect.bottom + 8 : (point?.y ?? 80);
|
||||
const mw = menu.offsetWidth;
|
||||
const mh = menu.offsetHeight;
|
||||
let left = px - mw / 2;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
|
||||
let top = belowY;
|
||||
if (top + mh > window.innerHeight - 8) top = Math.max(8, (rect ? rect.top : belowY) - mh - 8);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
menu.addEventListener('click', (e) => {
|
||||
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-i]') : null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const idx = Number(btn.dataset.i);
|
||||
const action = actions[idx];
|
||||
if (!action || action.disabled) return;
|
||||
close();
|
||||
if (typeof action.onClick === 'function') action.onClick();
|
||||
});
|
||||
|
||||
return close;
|
||||
}
|
||||
426
shine-UI/styles/network-graph.css
Normal file
426
shine-UI/styles/network-graph.css
Normal file
@ -0,0 +1,426 @@
|
||||
/* ============================================================================
|
||||
Force-directed карта связей (.fg-*) — интерактивный граф на странице «Связи».
|
||||
Узлы позиционируются трансформами (GPU), рёбра — отдельный SVG-слой.
|
||||
Отдельный модуль, чтобы не раздувать components.css.
|
||||
========================================================================== */
|
||||
|
||||
.fg-stage {
|
||||
touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: grab;
|
||||
background: radial-gradient(circle at 50% 42%, rgba(83, 216, 251, 0.07), rgba(255, 255, 255, 0.01) 60%);
|
||||
}
|
||||
|
||||
.fg-stage:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.fg-world {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
will-change: transform;
|
||||
z-index: 1; /* узлы и подписи строго над линиями связей */
|
||||
}
|
||||
|
||||
.fg-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
z-index: 0; /* линии связей — под узлами */
|
||||
transition: opacity 420ms ease; /* плавное появление линий при перестройке */
|
||||
}
|
||||
|
||||
.fg-node {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none; /* iOS: не показывать системное меню по долгому тапу */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */
|
||||
.fg-node .node-dot {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
transition: box-shadow 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.fg-node.is-family .node-dot {
|
||||
background: linear-gradient(165deg, #785038, #5f3e2c);
|
||||
border-color: rgba(255, 194, 143, 0.6);
|
||||
}
|
||||
|
||||
.fg-node.is-friend .node-dot {
|
||||
background: linear-gradient(165deg, #2f4f80, #2a3f62);
|
||||
border-color: rgba(150, 190, 255, 0.5);
|
||||
}
|
||||
|
||||
.fg-node.is-business .node-dot {
|
||||
background: linear-gradient(165deg, #4a3b7a, #2f2750);
|
||||
border-color: rgba(196, 165, 255, 0.55);
|
||||
}
|
||||
|
||||
.fg-node.is-contact .node-dot {
|
||||
background: linear-gradient(165deg, #36435c, #283142);
|
||||
border-color: rgba(180, 200, 226, 0.4);
|
||||
}
|
||||
|
||||
.fg-node.is-focus .node-dot {
|
||||
background: linear-gradient(130deg, #3a5f8e, #3dc4df);
|
||||
color: #061119;
|
||||
border-color: rgba(180, 230, 255, 0.85);
|
||||
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45);
|
||||
}
|
||||
|
||||
.fg-node:focus-visible .node-dot,
|
||||
.fg-node:hover .node-dot {
|
||||
border-color: rgba(166, 218, 255, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35);
|
||||
}
|
||||
|
||||
/* пульсирующее свечение «сияющих» узлов */
|
||||
.fg-node.is-shine .node-dot::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(130, 235, 255, 0.6) 0%, rgba(130, 235, 255, 0.26) 44%, rgba(130, 235, 255, 0) 76%);
|
||||
filter: blur(2px);
|
||||
z-index: -1;
|
||||
animation: fg-shine-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fg-shine-pulse {
|
||||
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
||||
50% { transform: scale(1.16); opacity: 0.95; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fg-node.is-shine .node-dot::before { animation: none; }
|
||||
}
|
||||
|
||||
/* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */
|
||||
.fg-node.is-focus .node-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(130, 235, 255, 0.32) 0%, rgba(130, 235, 255, 0) 70%);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* «Дыхание» фокуса — бесконечная очень мягкая пульсация РАЗМЕРА (база 1.5x → 1.48–1.52x),
|
||||
период 4с. CSS-анимация на transform (GPU) — НЕ будит rAF-цикл физики; интерфейс «живой». */
|
||||
.fg-node.is-focus .node-dot {
|
||||
animation: fg-focus-breath 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fg-focus-breath {
|
||||
0%, 100% { transform: scale(0.987); }
|
||||
50% { transform: scale(1.013); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fg-node.is-focus .node-dot { animation: none; }
|
||||
}
|
||||
|
||||
/* подпись под узлом — абсолютная, чтобы не влиять на размер бокса (центрирование по трансформу) */
|
||||
.fg-node-label {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 5px;
|
||||
max-width: 110px;
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
color: #d6e2ff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fg-node.is-secondary .fg-node-label {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Имя центрального узла — на подложке, чтобы линии связей не просвечивали сквозь текст */
|
||||
.fg-node.is-focus .fg-node-label {
|
||||
margin-top: 7px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9px;
|
||||
background: rgba(8, 14, 24, 0.74);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
color: #f4f8ff;
|
||||
font-weight: 600;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Лёгкая точка (узлы сверх хард-лимита DOM) — без аватара/подписи */
|
||||
.fg-dot {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: #36435c;
|
||||
box-shadow: 0 2px 6px rgba(4, 8, 15, 0.4);
|
||||
}
|
||||
|
||||
.fg-dot.is-family { background: #6f4a34; border-color: rgba(255, 194, 143, 0.5); }
|
||||
.fg-dot.is-friend { background: #2f4f80; border-color: rgba(150, 190, 255, 0.45); }
|
||||
.fg-dot.is-business { background: #4a3b7a; border-color: rgba(196, 165, 255, 0.5); }
|
||||
.fg-dot.is-contact { background: #36435c; }
|
||||
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
|
||||
|
||||
/* «Прицел» в центре экрана (зона фокуса) — позади узлов */
|
||||
.fg-reticle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: -32px 0 0 -32px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed rgba(150, 190, 255, 0.3);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.45;
|
||||
transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
.fg-reticle.is-locked {
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
margin: -47px 0 0 -47px;
|
||||
border-color: rgba(130, 235, 255, 0.65);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* «Призрак» старой карты при Z-переходе (эффект погружения) */
|
||||
.fg-ghost-layer {
|
||||
position: absolute;
|
||||
inset: 0; /* полноэкранный overlay → клон линий/узлов совпадает по координатам */
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5; /* стартовая прозрачность шлейфа выше → дольше читается (JS уводит в 0) */
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 800ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Импульс центрального кольца при захвате нового фокуса */
|
||||
.fg-reticle.is-pulse {
|
||||
animation: fg-reticle-pulse 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes fg-reticle-pulse {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Панель фильтров слоёв (оверлей под шапкой) */
|
||||
.fg-filter-bar {
|
||||
position: absolute;
|
||||
top: max(54px, calc(env(safe-area-inset-top) + 50px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fg-filter-chip {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(166, 196, 245, 0.28);
|
||||
background: rgba(10, 20, 37, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
color: #cfe0ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 7px 13px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.fg-filter-chip.is-active {
|
||||
background: linear-gradient(130deg, rgba(61, 196, 223, 0.92), rgba(58, 95, 142, 0.92));
|
||||
border-color: rgba(180, 230, 255, 0.85);
|
||||
color: #061119;
|
||||
}
|
||||
|
||||
/* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */
|
||||
.fg-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.fg-menu {
|
||||
position: fixed;
|
||||
min-width: 210px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
background: rgba(16, 24, 40, 0.97);
|
||||
border: 1px solid rgba(166, 196, 245, 0.28);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.fg-menu-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.fg-menu-login {
|
||||
font-weight: 700;
|
||||
color: #eaf1ff;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fg-menu-rel {
|
||||
font-size: 11px;
|
||||
color: #9fb6e0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fg-menu-item {
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #dfe9ff;
|
||||
font-size: 14px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fg-menu-item:hover {
|
||||
background: rgba(77, 160, 255, 0.16);
|
||||
}
|
||||
|
||||
.fg-menu-item.is-stub {
|
||||
color: #7f8aa3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fg-menu-item.is-stub:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Нижний сниппет (bottom sheet) */
|
||||
.fg-sheet {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
z-index: 13;
|
||||
display: none;
|
||||
padding: 12px 14px 14px;
|
||||
background: rgba(16, 24, 40, 0.95);
|
||||
border: 1px solid rgba(166, 196, 245, 0.26);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.fg-sheet.is-open {
|
||||
display: block;
|
||||
animation: fg-sheet-in 200ms ease;
|
||||
}
|
||||
|
||||
@keyframes fg-sheet-in {
|
||||
from { transform: translateY(16px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.fg-sheet-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #9fb6e0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fg-sheet-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: #eaf1ff;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fg-sheet-badge {
|
||||
font-size: 10px;
|
||||
background: rgba(130, 235, 255, 0.2);
|
||||
color: #bff0ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fg-sheet-rel {
|
||||
font-size: 12px;
|
||||
color: #9fb6e0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.fg-sheet-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fg-sheet-actions > button {
|
||||
flex: 1;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user