merge(ui): влить pixel-08.06 в main

This commit is contained in:
AidarKC 2026-06-09 22:20:49 +04:00
commit 105a56499d
10 changed files with 2205 additions and 419 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.147
server.version=1.2.139
client.version=1.2.148
server.version=1.2.140

View File

@ -0,0 +1,95 @@
# Интерактивная карта связей (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-слой.
- **CSS-bloom (разлёт без тряски):** разлёт/перелёт узлов делают нативные CSS-переходы на
`transform` (компоновщик, `cubic-bezier(0.16,1,0.3,1)`, `BLOOM_MS` со ступенчатой задержкой
`order × 40мс`), а НЕ JS-физика. Работает даже при троттлинге rAF; цикл лишь ведёт лучи за узлами
(`syncPositionsFromDOM`). Завершение — гарантированно по таймеру (`endCssBloom`).
- **Ghost-слой:** снимок только **аватарок** старого графа (без линий — иначе старые связи висят
«ошмётками»). Полноэкранный overlay, застывает на месте, `scale 1→0.7` + `opacity 0.5→0` за
**1000мс**, затем удаляется (мягкий породистый шлейф истории).
- **Прорастание линий (Edge Growth):** новая линия тянется к ФИНАЛЬНОЙ точке узла и раскрывается
`stroke-dasharray`(=длина пути) + `stroke-dashoffset`(длина→0), синхронно с разлётом узла
(`growP = текущая дистанция / финальная`) → кончик «вытягивается» из центра вслед за аватаркой.
Старые линии при этом исчезают мгновенно. Только для новых узлов; переезжающие — линия следует за ними.
- **Физика (только до-settle):** после CSS-разлёта — лёгкая радиальная пружина + отталкивание для
органичного покачивания; после фильтра физика НЕ включается (фиксация на равномерных углах).
- **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 скорости обнуляются, координаты
округляются, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает.
- **Обычные линии:** SVG `<path> Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.01.2`),
градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются
в фоне, не спорят с сияющими). Изгиб реагирует на скорость.
- **Сияющие связи (двухслойный «световод», Neon Layering):** два пути на одну связь — широкий размытый
GLOW (`stroke-width 4`, неон, `filter: blur(2px)`, `opacity 0.4`) + тонкий чёткий CORE (`1.5px`,
`#e0f7fc`). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий
dashoffset). Никаких бегущих импульсов.
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не
перехватила указатель (`setPointerCapture`) и не «съела» click кнопки.
- **Фильтры слоёв (Все / Семья / Друзья / Сияющие):** CSS-переходы 300мс — несоответствующие узлы и их
линии гаснут НА МЕСТЕ (`opacity 0` + `scale 0.8`), оставшиеся плавно переплывают на равномерные углы,
затем жёсткая фиксация без физики (ноль тряски, мгновенный sleep).
- **Живой фон (Nebula):** под центром — глубокое размытое сине-голубое облако (`.fg-stage::before`,
`blur 80→96px`), бесконечная анимация 7с: «дышит» радиусом/яркостью и переливается индиго↔ультрамарин
(`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными.
- **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`,
`backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым.
- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
хард-лимит ~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.80 / 0.94 / 42 | базовое трение / стартовая вязкость / длительность (~700мс) |
| `BLOOM_MS` / `BLOOM_STAGGER` | 900 / 40 | длительность CSS-разлёта / задержка между узлами (каскад) |
| `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки |
| `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса |
| `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) |
Прочее (вшито в код): Ghost-слой — 1000мс; CSS-переход фильтра — 300мс; пульсация сияния — 3.6с;
прорастание линий привязано к прогрессу разлёта узла (а не к отдельному таймеру).
## Локальный запуск / проверка
- 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 (отдаёт только прямые связи) — требуют доработки сервера.
- Превью в простое троттлит `requestAnimationFrame` (физика не идёт между вызовами) — для замеров
прокачивать кадры; в активном табе всё работает на 60 FPS.

View File

@ -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';

View File

@ -280,3 +280,127 @@ 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', 'marina', 'nina']);
// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории.
// Если сети нет — узлы мягко падают на инициалы (img.onerror).
const NETWORK_PHOTOS = {
ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5',
pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9',
dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52',
nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44',
sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60',
};
function networkConn(login, relationType, connectionStrength) {
return {
id: login,
login,
name: NETWORK_NAMES[login] || login,
avatar: null,
photo: NETWORK_PHOTOS[login] || 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,
photo: NETWORK_PHOTOS[login] || 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),
]),
};

View File

@ -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,24 @@ export function render({ navigate, route }) {
}
setBackButtonState(backBtnEl);
// Панель фильтров слоёв (оверлей под шапкой)
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
chip.textContent = FILTERS[key].label;
chip.addEventListener('click', () => applyFilter(key));
filterChips[key] = chip;
filterBar.append(chip);
});
header.classList.add('network-header-overlay');
stage.append(board, header);
stage.append(board, header, filterBar);
screen.append(stage);
return screen;
}

View 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] };
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,131 @@
// Лабораторный режим карты связей (network-view/lab).
//
// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование,
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально).
import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js';
import { createForceGraph, buildModelFromTz } from './force-graph.js';
import { openNodeMenu } from './node-menu.js';
const START_LOGIN = 'ivan';
// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам;
// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие».
const FILTERS = {
all: { label: 'Все', pred: () => true },
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
function helpText() {
return [
'Лаборатория карты связей (мок-данные, без сервера).',
'• Тащите по экрану — карта свободно перемещается (pan).',
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
}
// Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей).
function graphForLogin(login) {
const key = String(login || '').trim().toLowerCase();
if (networkGraphUsers[key]) return networkGraphUsers[key];
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
}
export function renderNetworkLab({ navigate }) {
const screen = document.createElement('section');
screen.className = 'network-screen';
const appScreenEl = document.getElementById('app-screen');
appScreenEl?.classList.add('network-scroll-lock');
const stage = document.createElement('div');
stage.className = 'network-stage fg-stage';
const header = renderHeader({
title: 'Связи · лаборатория',
leftAction: {
label: '←',
onClick: () => navigate('network-view'),
},
rightActions: [
{ label: '?', onClick: () => window.alert(helpText()) },
],
});
header.classList.add('network-header-overlay');
const model = buildModelFromTz(graphForLogin(START_LOGIN));
// Состояние активного слоя (как в network-view): фокус всегда виден.
let activeFilter = 'all';
const filterChips = {};
function applyFilter(key) {
if (!FILTERS[key]) return;
activeFilter = key;
FILTER_ORDER.forEach((k) => {
const el = filterChips[k];
if (el) el.classList.toggle('is-active', k === activeFilter);
});
graph.setFilter(FILTERS[key].pred);
}
stage.append(header);
screen.append(stage);
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
stage,
model,
// тап по узлу — переключаем карту на сеть выбранного человека
onNodeTap: (node) => {
graph.setModel(buildModelFromTz(graphForLogin(node.login)));
// сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
},
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
onNodeLongPress: (node, point) => openNodeMenu({
login: node.name || node.login || node.id,
relationType: node.relationType,
point,
actions: [
{ label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) },
{ label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) },
],
}),
});
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
chip.textContent = FILTERS[key].label;
chip.addEventListener('click', () => applyFilter(key));
filterChips[key] = chip;
filterBar.append(chip);
});
stage.append(filterBar);
screen.cleanup = () => {
graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock');
};
return screen;
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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;
}

View File

@ -0,0 +1,498 @@
/* ============================================================================
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;
}
/* Живой фон-«небула»: глубокое размытое сине-голубое облако света строго под центральным узлом.
Медленно «дышит» (радиус/яркость) и переливается индигоультрамарин (hue-rotate) за 7с.
Чистый CSS на компоновщике создаёт ощущение живой светящейся среды, не будит rAF-цикл. */
.fg-stage::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle 320px at 50% 47%, rgba(80, 150, 255, 0.30) 0%, rgba(60, 100, 220, 0.15) 42%, rgba(40, 70, 170, 0) 72%);
filter: blur(80px);
pointer-events: none;
z-index: 0; /* строго под линиями (z:0, но раньше по порядку) и узлами (z:1) */
animation: fg-nebula 7s ease-in-out infinite;
}
@keyframes fg-nebula {
0%, 100% { opacity: 0.70; filter: blur(80px) hue-rotate(-12deg); }
50% { opacity: 1.00; filter: blur(96px) hue-rotate(16deg); }
}
@media (prefers-reduced-motion: reduce) {
.fg-stage::before { animation: none; }
}
.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; /* плавное появление линий при перестройке */
}
/* Сияющая связь = двухслойный неоновый «световод» (Neon Layering): изящно, но объёмно (как OLED).
GLOW широкий размытый ореол неонового оттенка под линией; CORE тонкий чёткий светлый контур. */
.fg-edge-glow {
fill: none;
stroke: rgba(110, 225, 255, 1);
stroke-width: 4;
stroke-linecap: round;
filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
}
.fg-edge-core {
fill: none;
stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
stroke-width: 1.5;
stroke-linecap: round;
}
.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);
}
/* «Сияние» мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр).
Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 1.0
как мягкое свечение живого организма в темноте, а не «жирный маркер». */
.fg-node.is-shine .node-dot {
border-color: rgba(150, 240, 255, 0.62);
animation: fg-shine-glow 3.6s ease-in-out infinite;
}
/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */
.fg-node.is-shine .node-dot::before {
content: '';
position: absolute;
inset: -12px;
border-radius: 50%;
background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
filter: url(#fg-shine-glow);
z-index: -1;
pointer-events: none;
animation: fg-shine-halo 3.6s ease-in-out infinite;
}
/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */
@keyframes fg-shine-glow {
0%, 100% {
box-shadow:
0 0 5px rgba(125, 232, 255, 0.30),
0 0 11px rgba(112, 226, 255, 0.18),
0 0 20px rgba(100, 220, 255, 0.10);
}
50% {
box-shadow:
0 0 9px rgba(150, 245, 255, 0.62),
0 0 20px rgba(122, 236, 255, 0.42),
0 0 36px rgba(100, 220, 255, 0.26);
}
}
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
@keyframes fg-shine-halo {
0%, 100% { transform: scale(0.9); opacity: 0.5; }
50% { transform: scale(1.12); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.fg-node.is-shine .node-dot { animation: none; }
.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.481.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%;
/* лениво и породисто тает на месте за 1000мс — медленный дорогой шлейф истории перехода */
transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease;
}
/* Импульс центрального кольца при захвате нового фокуса */
.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;
}
/* Стеклянные табы — тонкие пластины матового стекла (frosted glass) */
.fg-filter-chip {
pointer-events: auto;
border: 0.5px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #cfe0ff;
font-size: 12px;
font-weight: 600;
line-height: 1;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08); /* лёгкий стеклянный блик сверху */
transition: background 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
/* Активный таб — то же стекло, но подсвеченное сине-голубым (в тон неону графа) */
.fg-filter-chip.is-active {
background: rgba(125, 215, 255, 0.16);
border-color: rgba(160, 230, 255, 0.55);
color: #eaf7ff;
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28);
}
/* Контекстное меню узла (долгое нажатие) — в #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;
}