Compare commits

..

No commits in common. "2559f1e66b8367552499289ced17a87589befcce386752db2dd2c9d9f5751692" and "3de992d251ba07285cf691477542ced74b9fe123e7bb19130b1a9f0f7f968e95" have entirely different histories.

6 changed files with 159 additions and 1412 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.158 client.version=1.2.141
server.version=1.2.142 server.version=1.2.127

View File

@ -61,7 +61,7 @@
(`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными. (`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными.
- **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`, - **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`,
`backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым. `backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым.
- **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация - **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`); `box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
хард-лимит ~90 DOM-аватарок (остальное — SVG-точки). хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
@ -88,94 +88,6 @@
тап по узлам переключает сети. тап по узлам переключает сети.
- Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма). - Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма).
## Режим «Интерактивная паутина» (ветка `pixel-web`, эксперимент, только лаборатория)
Включается чипом «🌌 Вселенная». Дальние уровни (2-3) по умолчанию скрыты и раскрываются локально:
- **Hover-превью (наведение):** навёл мышь/палец на узел — его ветка временно выплывает; убрал —
втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover`
`graph.setHover(node|null)`; узел получает флаг `hovered`.
- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка
остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его
(надёжный toggle: `isOpen = pinned || expandP>0.5`сброс `pinned`+`hovered`).
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
- **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25`
(узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp).
- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62`
(≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей.
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость.
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
дотягивается (`glideCameraTo` → `camTargetX/Y`, lerp `CAM_GLIDE_K` в tick). Любой жест отменяет доводчик.
- **Свободный зум:** колесо мыши (`onWheel`) и щипок двумя пальцами (`activePointers`/`pinching`) —
масштаб `zoom` (0.552.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-`scale`,
линии (отдельный SVG) пересчитываются в экранных координатах (× `zoom`).
- **Синхро-пульс линий:** сияющие/трековые «световоды» (`.fg-edge-glow`/`.fg-edge-core`) «дышат»
толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно).
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
### Умный фокус (Smart Zoom / «аквариум») — ветка `pixel-aquarium`
**Наведение** (hover/палец) на узел — лёгкое превью ветки (раскрытие на месте, без камеры).
**Клик/тап по ЛЮБОМУ узлу** — **погружение (dive)** с кинематографичным наездом:
- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K` ≈600мс):
узел плавно центрируется (offset ~0) и **вырастает до единого видимого размера** `HERO_VISUAL=1.4`
независимо от уровня (`depthScale = HERO_VISUAL / baseScaleOf`); его прямые дети — до `DIVE_CHILD_VISUAL`.
- **Адаптивный радиус орбиты (фикс слипания):** дети раскладываются на кольце
`ringR = max(baseR + радиус_родителя, числоетей × 13)`НЕ лезут на (увеличенный зумом) родитель
и друг на друга (проверено: мин. дистанция 125px, 0 наложений). Радиус растёт вместе с зумом родителя.
- **Глубина «аквариума»** (`contextTargetOf` → `depthScale`/`depthBlur`/`spotCur`, лерп): Иван и боковые
ветки **уменьшаются** (root ×0.55, фон ×0.55) + уходят в **blur 3px** + тускнеют до 0.25 → задний план.
- **Железный Spotlight (единый активный путь):** `diveTo` сначала гасит ВСЕ прежние pin/hover, затем
раскрывает только путь к новой цели. Открыто → путь Иван→…→узел = 1.0, остальное = 0.25; переключение
веток сбрасывает прежнюю; **выход/`exitDive`/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает**.
- **Нить-крошка**: путь (`divePathSet`/`onPath`) горит ярким «световодом» — виден путь назад к Ивану.
- **Pinch-to-Zoom + LOD**: щипок/колесо меняют `zoom`; при `zoom ≥ LOD_ZOOM (1.55)` видимые точки 3-го
уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки.
- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL.
**Полиш (партия 1):** веер детей раскрывается **полукругом «наружу»** (от пути назад, `DEEP_FAN`,
по `sibIndex`) — не перекрывает нить-крошку; **LOD с гистерезисом** (`LOD_ZOOM_UP=1.6`/`DOWN=1.4` — без
мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход;
**префетч аватарок** детей при наведении/нырке.
**Фишки (партия 2, лаборатория):**
- **Поиск + телепорт** — строка `.fg-search`; Enter → `graph.findNode(имя)` → камера летит к узлу (dive в
«Вселенной», иначе перецентр).
- **Хлебные крошки**`.fg-breadcrumb` «Иван Нина Ада» (движок шлёт `onDiveChange(path)`,
API `getDivePath()`); клик по корню — полный сброс, по предку — навигация на тот уровень.
- **Бейдж числа связей**`.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`).
- **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`).
**Линии-«жгуты» (партия 4, по референсу — плазменный композитинг):**
- **Сияющие** — ОДИН центральный S-путь (cubic Bézier) + ТРИ наложенных слоя с ОДИНАКОВЫМ `d` (объём
из толщины+размытия, НЕ из геометрии — никаких расходящихся линий):
Настоящий НЕОН (видимый ореол вокруг яркого ядра; поле/трубка в `mix-blend-mode: screen` — свет
складывается аддитивно с тёмным фоном, а в пересечениях у центра ярче — энергохаб):
- `.fg-plasma-flare` — плазменное облако: 16px, `#00bfff`, opacity 0.42, **`feGaussianBlur` stdDev=6**, screen (+ «дыхание» 3.6с);
- `.fg-plasma-tube` — направляющий свет: 6px, `#00e5ff`, opacity 0.85, **`feGaussianBlur` stdDev=2**, screen;
- `.fg-plasma-core` — ядро: 2px, `#dffaff` (светло-голубо-белое), opacity 1, без размытия.
Толщина/насыщенность подогнаны под референс (толстая яркая голубая плазма, гладкие края).
S-волна спокойная/изящная (amp до 13px). Размытие — именно SVG-фильтры (`#fg-plasma-blur6/2`), т.к.
CSS-`filter` на `<path>` в части мобильных WebView не применяется (отсюда был «плоский»/«канатный» вид).
⚠️ Это НЕ Canvas-движок (не библиотека force-graph): связи — реальные SVG `<path>`, фильтры применяются.
Прозрачность слоёв inline (× spotlight/глубину). Тяжёлый blur только у сияющих (их мало) — перф.
- **Не-сияущие** — мягкое свечение **в цвете связи** (семья/друзья/бизнес/контакт): широкая
полупрозрачная подложка + тонкое ядро, без SVG-blur (дёшево). «Похоже, но тише».
**Фишки (партия 3, лаборатория):**
- **Общие связи** — среди друзей человека один помечен как «общий» (он и твой друг тоже): золотой
ободок + ★ (CSS `is-common`; в лаб-генерации `addDeepLevels` подставляет узнаваемого друга Ивана).
- **Доступность** — визуально скрытый (`sr-only`) текстовый список графа `.fg-a11y` (центр + связи
1-го уровня) для скринридеров; обновляется в `updateA11y` при перестроении. Полезно и для реального пути.
**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`,
прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через
детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры
до покоя — не зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны.
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.
> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2
> (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`.
## Ограничения / на будущее ## Ограничения / на будущее
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи» - Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
упираются в API (отдаёт только прямые связи) — требуют доработки сервера. упираются в API (отдаёт только прямые связи) — требуют доработки сервера.

File diff suppressed because it is too large Load Diff

View File

@ -4,10 +4,6 @@
// центрирование и навигацию между пользователями. Используется связанный мульти-граф // центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает // networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально). // карту на сеть этого человека (как реальный путь, но локально).
//
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
// Это чисто визуальный лабораторный эксперимент на мок-данных.
import { renderHeader } from '../../components/header.js'; import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js'; import { networkGraphUsers } from '../../mock-data.js';
@ -26,18 +22,6 @@ const FILTERS = {
}; };
const FILTER_ORDER = ['all', 'family', 'friends', 'shining']; const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
function seed01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
function helpText() { function helpText() {
return [ return [
'Лаборатория карты связей (мок-данные, без сервера).', 'Лаборатория карты связей (мок-данные, без сервера).',
@ -46,13 +30,6 @@ function helpText() {
'• Тап по центральному узлу — здесь открылся бы профиль.', '• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).', '• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.', '• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'', '',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.', 'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n'); ].join('\n');
@ -65,104 +42,6 @@ function graphForLogin(login) {
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] }; return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
} }
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
function synthTier1(focusId) {
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
const out = [];
for (let i = 0; i < k; i += 1) {
const id = `${focusId}__t1_${i}`;
const s = seed01(id);
out.push({
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
avatar: null, photo: null,
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
connectionStrength: 0.5 + s * 0.4,
status: s > 0.78 ? 'shining' : '',
hasOwnConnections: true, tier: 1,
});
}
return out;
}
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
function addDeepLevels(model) {
const focusId = model.focusId;
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
const extra = [];
tier1.forEach((p) => {
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
const others = tier1.filter((o) => String(o.id) !== String(p.id));
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
if (i === 0 && common) {
extra.push({
id: id2, login: id2, name: common.name || common.login,
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
});
continue;
}
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
extra.push({
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
avatar: null, photo: face3, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
}
});
return { focusId, nodes: [...model.nodes, ...extra] };
}
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
function buildLabModel(login, deep, fromLogin) {
const tz = graphForLogin(login);
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
else tz.connections = [];
}
const base = buildModelFromTz(tz);
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
const fid = String(fromLogin);
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
if (found) {
found.track = true; // уже среди связей — просто подсветим трек
} else {
const f = graphForLogin(fromLogin).focusUser || {};
base.nodes.push({
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
photo: f.photo || null, relationType: 'friend', strength: 0.97,
shining: false, tier: 1, track: true,
});
}
}
return deep ? addDeepLevels(base) : base;
}
export function renderNetworkLab({ navigate }) { export function renderNetworkLab({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'network-screen'; screen.className = 'network-screen';
@ -175,13 +54,17 @@ export function renderNetworkLab({ navigate }) {
const header = renderHeader({ const header = renderHeader({
title: 'Связи · лаборатория', title: 'Связи · лаборатория',
leftAction: { label: '←', onClick: () => navigate('network-view') }, leftAction: {
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }], label: '←',
onClick: () => navigate('network-view'),
},
rightActions: [
{ label: '?', onClick: () => window.alert(helpText()) },
],
}); });
header.classList.add('network-header-overlay'); header.classList.add('network-header-overlay');
let centerLogin = START_LOGIN; const model = buildModelFromTz(graphForLogin(START_LOGIN));
let deepMode = false;
// Состояние активного слоя (как в network-view): фокус всегда виден. // Состояние активного слоя (как в network-view): фокус всегда виден.
let activeFilter = 'all'; let activeFilter = 'all';
@ -199,41 +82,18 @@ export function renderNetworkLab({ navigate }) {
stage.append(header); stage.append(header);
screen.append(stage); screen.append(stage);
const model = buildLabModel(centerLogin, deepMode);
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом // Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM. // (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({ const graph = createForceGraph({
stage, stage,
model, model,
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла); // тап по узлу — переключаем карту на сеть выбранного человека
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => { onNodeTap: (node) => {
if (deepMode) { graph.setModel(buildModelFromTz(graphForLogin(node.login)));
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка // сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
graph.diveTo(node);
return;
}
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
const from = centerLogin;
centerLogin = node.login || node.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred); if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
}, },
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором, onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
// Изменение пути погружения → перерисовываем хлебные крошки (Иван Нина Ада).
onDiveChange: (path) => renderBreadcrumb(path),
onCenterTap: (node) => {
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
if (deepMode) { graph.collapseAll(); return; }
window.alert(`Профиль: ${node.name || node.login || node.id}`);
},
onNodeLongPress: (node, point) => openNodeMenu({ onNodeLongPress: (node, point) => openNodeMenu({
login: node.name || node.login || node.id, login: node.name || node.login || node.id,
relationType: node.relationType, relationType: node.relationType,
@ -248,7 +108,8 @@ export function renderNetworkLab({ navigate }) {
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view. // Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar'; filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click). // Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation()); filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => { FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button'); const chip = document.createElement('button');
@ -259,79 +120,8 @@ export function renderNetworkLab({ navigate }) {
filterChips[key] = chip; filterChips[key] = chip;
filterBar.append(chip); filterBar.append(chip);
}); });
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
const deepChip = document.createElement('button');
deepChip.type = 'button';
deepChip.className = 'fg-filter-chip fg-deep-chip';
deepChip.textContent = '🌌 Вселенная';
deepChip.addEventListener('click', () => {
deepMode = !deepMode;
deepChip.classList.toggle('is-active', deepMode);
graph.setModel(buildLabModel(centerLogin, deepMode));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
});
filterBar.append(deepChip);
stage.append(filterBar); stage.append(filterBar);
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
const searchWrap = document.createElement('div');
searchWrap.className = 'fg-search';
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
const searchIco = document.createElement('span');
searchIco.className = 'fg-search-ico';
searchIco.textContent = '🔍';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Найти человека…';
searchInput.setAttribute('aria-label', 'Поиск по имени');
function doSearch() {
const hit = graph.findNode(searchInput.value);
if (!hit) return;
if (deepMode) {
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
} else {
const from = centerLogin;
centerLogin = hit.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
}
searchInput.blur();
}
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
searchWrap.append(searchIco, searchInput);
stage.append(searchWrap);
// --- Хлебные крошки: стек погружений (Иван Нина Ада); клик по крошке — навигация назад ---
breadcrumbEl = document.createElement('div');
breadcrumbEl.className = 'fg-breadcrumb';
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
stage.append(breadcrumbEl);
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
function renderBreadcrumb(path) {
if (!breadcrumbEl) return;
breadcrumbEl.innerHTML = '';
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
breadcrumbEl.classList.toggle('is-open', open);
if (!open) return;
path.forEach((p, i) => {
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = ''; breadcrumbEl.append(sep); }
const c = document.createElement('button');
c.type = 'button';
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
c.textContent = p.name;
if (i < path.length - 1) {
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
}
breadcrumbEl.append(c);
});
}
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
window.__fg = graph;
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
}
screen.cleanup = () => { screen.cleanup = () => {
graph.destroy(); graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock'); appScreenEl?.classList.remove('network-scroll-lock');

View File

@ -1,150 +0,0 @@
// Автопроверки интерактивного графа связей (dev-only).
//
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
//
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
export async function runNetworkSelfTest(graph, deepChipEl) {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
const st = () => graph.debugState();
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
await wait(1700);
let s = st();
const focusId = s.focusId;
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
graph.diveTo({ id: parent.id });
const framesA = graph.pumpForTest();
s = st();
const p = s.nodes.find((n) => n.id === parent.id);
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
// расталкивание: дети не слипаются
let minD = Infinity;
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
}
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
const outward = Math.atan2(p.y, p.x);
const maxDev = kids.reduce((mx, k) => {
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
if (d > Math.PI) d = 2 * Math.PI - d;
return Math.max(mx, d * 180 / Math.PI);
}, 0);
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
const offPath = tier1.filter((n) => n.id !== parent.id);
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
if (offPath.length) {
graph.diveTo({ id: offPath[0].id });
graph.pumpForTest();
s = st();
const prev = s.nodes.find((n) => n.id === parent.id);
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
}
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
const t2withKids = st().nodes.find((n) => n.tier === 2);
if (t2withKids) {
graph.diveTo({ id: t2withKids.id });
graph.pumpForTest();
s = st();
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
}
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
if (named && typeof graph.findNode === 'function') {
const byId = graph.findNode(named.id);
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
}
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
if (typeof graph.getDivePath === 'function' && t2withKids) {
const path = graph.getDivePath();
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' ')}`);
}
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
if (typeof document !== 'undefined') {
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
}
// === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) ===
if (typeof document !== 'undefined') {
const commonCount = document.querySelectorAll('.fg-node.is-common').length;
check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`);
}
// === Тест J: доступность — текстовый список графа для скринридеров ===
if (typeof document !== 'undefined') {
const a11y = document.querySelector('.fg-a11y');
const liCount = a11y ? a11y.querySelectorAll('li').length : 0;
check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`);
}
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
graph.exitDive();
graph.pumpForTest();
s = st();
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
if (typeof document !== 'undefined') {
const flare = document.querySelectorAll('.fg-plasma-flare');
const tube = document.querySelectorAll('.fg-plasma-tube');
const core = document.querySelectorAll('.fg-plasma-core');
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
}
return finish(results);
}
function finish(results) {
const pass = results.filter((r) => r.pass).length;
const out = { pass, total: results.length, results };
if (typeof window !== 'undefined') window.__fgTestResults = out;
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
// eslint-disable-next-line no-console
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name}${r.detail}`));
return out;
}

View File

@ -60,43 +60,20 @@
transition: opacity 420ms ease; /* плавное появление линий при перестройке */ transition: opacity 420ms ease; /* плавное появление линий при перестройке */
} }
/* Сияющая связь = плазменный композитинг (3 слоя на ОДНОМ S-пути, см. renderEdges). /* Сияющая связь = двухслойный неоновый «световод» (Neon Layering): изящно, но объёмно (как OLED).
Настоящий НЕОН: яркое светлое ядро + ВИДИМЫЙ голубой ореол вокруг. Слои поля/трубки идут в режиме GLOW широкий размытый ореол неонового оттенка под линией; CORE тонкий чёткий светлый контур. */
mix-blend-mode: screen свет складывается аддитивно с тёмным фоном (как реальное свечение), а в точках .fg-edge-glow {
пересечения нитей у центра ярче (энергетический хаб). Прозрачность слоёв inline (×spotlight/глубину). */
.fg-plasma-flare { /* нижний: широкое насыщенное голубое плазменное свечение (по референсу) */
fill: none; fill: none;
stroke: #00bfff; /* глубокий голубой */ stroke: rgba(110, 225, 255, 1);
stroke-width: 16; stroke-width: 4;
stroke-linecap: round; stroke-linecap: round;
filter: url(#fg-plasma-blur6); /* мягкое объёмное свечение (гладкие края — как на референсе) */ filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
mix-blend-mode: screen; /* аддитивное свечение поверх тёмного фона */
/* синхро-«дыхание» поля толщиной в такт ободку сияющего узла (3.6с); прозрачность не трогаем (inline) */
animation: fg-plasma-breath 3.6s ease-in-out infinite;
} }
.fg-plasma-tube { /* средний: яркая толстая неоновая трубка */ .fg-edge-core {
fill: none; fill: none;
stroke: #00e5ff; /* яркий циан */ stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
stroke-width: 6; stroke-width: 1.5;
stroke-linecap: round; stroke-linecap: round;
filter: url(#fg-plasma-blur2); /* SVG feGaussianBlur stdDeviation=2 */
mix-blend-mode: screen; /* аддитивное свечение */
}
.fg-plasma-core { /* верхний: яркое чёткое ядро (светло-голубо-белое) */
fill: none;
stroke: #dffaff; /* светло-голубо-белое — «жидкое» ядро */
stroke-width: 2;
stroke-linecap: round;
}
/* мягкое «дыхание» плазменного облака толщиной, синхронно с пульсом сияющего ободка (3.6с) */
@keyframes fg-plasma-breath {
0%, 100% { stroke-width: 14; }
50% { stroke-width: 19; }
}
@media (prefers-reduced-motion: reduce) {
.fg-plasma-flare { animation: none; }
} }
.fg-node { .fg-node {
@ -125,7 +102,7 @@
height: 52px; height: 52px;
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; transition: box-shadow 160ms ease, border-color 160ms ease;
} }
.fg-node.is-family .node-dot { .fg-node.is-family .node-dot {
@ -155,18 +132,10 @@
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45); box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45);
} }
/* Тактильный отклик «нажатия вглубь»: аватарка слегка вдавливается (scale 0.92), а неоновое кольцо
вспыхивает заметно ярче (~1.5×). Срабатывает при наведении, фокусе и зажатии (.is-pressed). */
.fg-node:focus-visible .node-dot, .fg-node:focus-visible .node-dot,
.fg-node:hover .node-dot, .fg-node:hover .node-dot {
.fg-node.is-pressed .node-dot { border-color: rgba(166, 218, 255, 0.95);
transform: scale(0.92); box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35);
border-color: rgba(160, 240, 255, 0.95);
box-shadow: 0 0 0 2px rgba(150, 238, 255, 0.6), 0 0 22px rgba(120, 230, 255, 0.85);
}
@media (prefers-reduced-motion: reduce) {
.fg-node.is-pressed .node-dot { transform: none; }
} }
/* «Сияние» мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи. /* «Сияние» мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
@ -296,50 +265,28 @@
.fg-dot.is-contact { background: #36435c; } .fg-dot.is-contact { background: #36435c; }
.fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); }
/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */ /* «Прицел» в центре экрана (зона фокуса) — позади узлов */
/* 2-й уровень «друзья друзей»: полноценная аватарка (лицо + имя), просто мельче основных. .fg-reticle {
Масштаб/прозрачность задаёт движок; здесь читаемый ободок и подпись (не «дырка»). */ position: absolute;
.fg-node.is-tier2 .node-dot { left: 50%;
border-color: rgba(170, 200, 240, 0.65); top: 50%;
box-shadow: 0 2px 8px rgba(4, 8, 15, 0.4); width: 64px;
} height: 64px;
.fg-node.is-tier2 .fg-node-label { margin: -32px 0 0 -32px;
font-size: 9px; border-radius: 50%;
opacity: 0.9; border: 2px dashed rgba(150, 190, 255, 0.3);
top: calc(100% + 1px); 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;
} }
/* 3-й уровень — микрозвезда: светящаяся точка без картинки (эффект далёкого созвездия). */ .fg-reticle.is-locked {
.fg-dot.is-tier3 { width: 94px;
width: 9px; height: 94px;
height: 9px; margin: -47px 0 0 -47px;
border: 0; border-color: rgba(130, 235, 255, 0.65);
background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%); opacity: 0.85;
box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5);
/* медленное мерцание «звезды» по box-shadow/яркости (НЕ opacity/scale: ими управляет движок при
раскрытии). У каждой звезды своя задержка (inline animation-delay) живое созвездие. */
animation: fg-star-twinkle 3.4s ease-in-out infinite;
}
.fg-dot.is-tier3.is-shine {
background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%);
box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7);
}
@keyframes fg-star-twinkle {
0%, 100% { box-shadow: 0 0 3px rgba(150, 220, 255, 0.45), 0 0 7px rgba(115, 200, 255, 0.25); filter: brightness(0.78); }
50% { box-shadow: 0 0 7px rgba(165, 235, 255, 0.95), 0 0 15px rgba(120, 210, 255, 0.6); filter: brightness(1.3); }
}
@media (prefers-reduced-motion: reduce) {
.fg-dot.is-tier3 { animation: none; }
}
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
.fg-deep-chip.is-active {
background: rgba(150, 130, 255, 0.18);
border-color: rgba(190, 170, 255, 0.6);
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3);
color: #efeaff;
} }
/* «Призрак» старой карты при Z-переходе (эффект погружения) */ /* «Призрак» старой карты при Z-переходе (эффект погружения) */
@ -354,6 +301,17 @@
transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease; 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 { .fg-filter-bar {
position: absolute; position: absolute;
@ -538,141 +496,3 @@
.fg-sheet-actions > button { .fg-sheet-actions > button {
flex: 1; flex: 1;
} }
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */
.fg-node-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: rgba(16, 24, 40, 0.92);
border: 1px solid rgba(150, 200, 255, 0.5);
color: #d9ecff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.fg-node.is-focus .fg-node-badge {
background: rgba(61, 196, 223, 0.95);
border-color: rgba(220, 245, 255, 0.8);
color: #06131c;
}
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */
.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); }
.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); }
.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); }
.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); }
/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
/* Строка поиска (оверлей вверху, под панелью фильтров) */
.fg-search {
position: absolute;
top: max(92px, calc(env(safe-area-inset-top) + 88px));
left: 50%;
transform: translateX(-50%);
z-index: 12;
width: min(280px, 70vw);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 0.5px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08);
}
.fg-search input {
flex: 1;
border: 0;
background: transparent;
color: #eaf2ff;
font-size: 13px;
outline: none;
}
.fg-search input::placeholder { color: #7d8aa6; }
.fg-search .fg-search-ico { color: #9fc0ff; font-size: 13px; }
/* Хлебные крошки навигации (стек погружений: Иван Нина Ада) */
.fg-breadcrumb {
position: absolute;
top: max(132px, calc(env(safe-area-inset-top) + 128px));
left: 0;
right: 0;
z-index: 12;
display: none;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px;
pointer-events: none;
}
.fg-breadcrumb.is-open { display: flex; }
.fg-crumb {
pointer-events: auto;
border: 0;
background: rgba(16, 24, 40, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #cfe0ff;
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 5px 10px;
border-radius: 999px;
cursor: pointer;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fg-crumb.is-last {
background: rgba(125, 215, 255, 0.18);
color: #eaf7ff;
cursor: default;
}
.fg-crumb-sep { color: #5f7196; font-size: 11px; align-self: center; pointer-events: none; }
/* Доступность: визуально скрытый список графа для скринридеров (sr-only, читается ассистивными технологиями) */
.fg-a11y {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */
.fg-node.is-common .node-dot {
border-color: rgba(255, 214, 120, 0.95);
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
}
.fg-node.is-common .node-dot::after {
content: '★';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
line-height: 1;
color: #ffd678;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
pointer-events: none;
}