Связи: полировка карты связей (свечение, прорастание линий, CSS-фильтры)
- Линии: тонкие дуги Безье (градиент неон-центр → цвет роли); связь к «сияющему» монолитно светится статичной тенью drop-shadow (без бегущих импульсов). - Прорастание новых линий из центра: stroke-dasharray/dashoffset синхронно с разлётом узла (кончик трекает аватарку); старые линии исчезают мгновенно. - Ghost-слой: только аватарки (без линий), 1000мс — нет висящих «ошмётков». - CSS-bloom разлёта на компоновщике (устойчив к троттлингу rAF; завершение по таймеру). - Сияющие узлы: мягкая медленная пульсация 3.6с (многослойная box-shadow + SVG-ореол); тестовые фото-аватарки. - Фильтры слоёв в лаборатории + фикс перехвата click сценой (stopPropagation на чипах); фейд скрываемых на месте (opacity 0 + scale 0.8, 300мс), фиксация без физики (ноль тряски). - Бамп client.version → 1.2.137; обновлена документация фичи. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f56e531384
commit
3e4759a0c9
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.136
|
client.version=1.2.137
|
||||||
server.version=1.2.127
|
server.version=1.2.127
|
||||||
|
|||||||
@ -25,26 +25,38 @@
|
|||||||
|
|
||||||
## Ключевые механики
|
## Ключевые механики
|
||||||
- **Diffing-переходы (непрерывность состояний):** при смене фокуса общие узлы (тот же `id`) не
|
- **Diffing-переходы (непрерывность состояний):** при смене фокуса общие узлы (тот же `id`) не
|
||||||
пересоздаются, а перелетают пружиной на новые места; новые «расцветают» (bloom) каскадом из центра;
|
пересоздаются, а перелетают на новые места; новые «расцветают» (bloom) каскадом из центра;
|
||||||
исчезнувшие уходят в Ghost-слой.
|
исчезнувшие уходят в Ghost-слой.
|
||||||
- **Ghost-слой:** снимок всего старого графа (узлы + линии) на полноэкранном overlay, застывает
|
- **CSS-bloom (разлёт без тряски):** разлёт/перелёт узлов делают нативные CSS-переходы на
|
||||||
на месте, `scale 1→0.7` + `opacity 0.5→0` за **800мс**, затем удаляется (красивый шлейф истории).
|
`transform` (компоновщик, `cubic-bezier(0.16,1,0.3,1)`, `BLOOM_MS` со ступенчатой задержкой
|
||||||
- **Физика:** мягкая радиальная пружина к орбите + взаимное отталкивание (charge) → органичная,
|
`order × 40мс`), а НЕ JS-физика. Работает даже при троттлинге rAF; цикл лишь ведёт лучи за узлами
|
||||||
слегка неровная орбита; фокус влетает в центр упруго. Координаты узлов на трансформах (GPU).
|
(`syncPositionsFromDOM`). Завершение — гарантированно по таймеру (`endCssBloom`).
|
||||||
- **Каскадный bloom:** новые узлы скрыты в центре и «выстреливают» по очереди (`order × 40мс`).
|
- **Ghost-слой:** снимок только **аватарок** старого графа (без линий — иначе старые связи висят
|
||||||
- **Динамическая вязкость:** первые ~600мс после перестроения трение завышено (0.92), отталкивание
|
«ошмётками»). Полноэкранный overlay, застывает на месте, `scale 1→0.7` + `opacity 0.5→0` за
|
||||||
ослаблено (×0.45) → гасит «взрыв», затем плавно к базе (0.82) — мягкое «резиновое» появление.
|
**1000мс**, затем удаляется (мягкий породистый шлейф истории).
|
||||||
|
- **Прорастание линий (Edge Growth):** новая линия тянется к ФИНАЛЬНОЙ точке узла и раскрывается
|
||||||
|
`stroke-dasharray`(=длина пути) + `stroke-dashoffset`(длина→0), синхронно с разлётом узла
|
||||||
|
(`growP = текущая дистанция / финальная`) → кончик «вытягивается» из центра вслед за аватаркой.
|
||||||
|
Старые линии при этом исчезают мгновенно. Только для новых узлов; переезжающие — линия следует за ними.
|
||||||
|
- **Физика (только до-settle):** после CSS-разлёта — лёгкая радиальная пружина + отталкивание для
|
||||||
|
органичного покачивания; после фильтра физика НЕ включается (фиксация на равномерных углах).
|
||||||
- **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 — скорости обнуляются, координаты
|
- **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 — скорости обнуляются, координаты
|
||||||
округляются до целых пикселей, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает.
|
округляются, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает.
|
||||||
- **Линии:** SVG `<path> Q` (квадратичные Безье) — изящные изогнутые нити, тонкие/полупрозрачные;
|
- **Линии:** SVG `<path> Q` (квадратичные Безье) — тонкие изящные дуги (`stroke-width ~1.3–2.2`),
|
||||||
при движении изгиб реагирует на скорость; новые линии прорастают (`stroke-dashoffset`).
|
градиент неон-центр → цвет роли. Изгиб реагирует на скорость.
|
||||||
|
- **Сияющие связи:** линия к «сияющему» узлу — ярче (градиент в неон) и МОНОЛИТНО светится статичной
|
||||||
|
тенью `filter: drop-shadow(...)` (тот же приём, что у ободка аватарки). Без бегущих импульсов.
|
||||||
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
||||||
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
||||||
по центру — профиль.
|
по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не
|
||||||
- **Фильтры слоёв:** Все / Семья / Друзья / Сияющие (плавное скрытие/перераспределение).
|
перехватила указатель (`setPointerCapture`) и не «съела» click кнопки.
|
||||||
- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса), «дыхание» фокуса (бесконечная CSS-анимация
|
- **Фильтры слоёв (Все / Семья / Друзья / Сияющие):** CSS-переходы 300мс — несоответствующие узлы и их
|
||||||
размера 1.48–1.52x, GPU, не будит rAF), свечение «сияющих», хард-лимит ~90 DOM-аватарок (остальное —
|
линии гаснут НА МЕСТЕ (`opacity 0` + `scale 0.8`), оставшиеся плавно переплывают на равномерные углы,
|
||||||
SVG-точки).
|
затем жёсткая фиксация без физики (ноль тряски, мгновенный sleep).
|
||||||
|
- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация
|
||||||
|
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
|
||||||
|
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
|
||||||
|
хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
|
||||||
|
|
||||||
## Параметры тюнинга (константы в начале `force-graph.js`)
|
## Параметры тюнинга (константы в начале `force-graph.js`)
|
||||||
| Константа | Значение | Назначение |
|
| Константа | Значение | Назначение |
|
||||||
@ -53,11 +65,15 @@
|
|||||||
| `K_RADIAL` | 0.035 | жёсткость орбитальной пружины (мягко) |
|
| `K_RADIAL` | 0.035 | жёсткость орбитальной пружины (мягко) |
|
||||||
| `K_FOCUS` | 0.12 | жёсткость пружины фокуса к центру |
|
| `K_FOCUS` | 0.12 | жёсткость пружины фокуса к центру |
|
||||||
| `CHARGE` / `CHARGE_START_FACTOR` | 1400 / 0.45 | отталкивание (на старте ослаблено) |
|
| `CHARGE` / `CHARGE_START_FACTOR` | 1400 / 0.45 | отталкивание (на старте ослаблено) |
|
||||||
| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.82 / 0.92 / 36 | базовое трение / стартовая вязкость / длительность (~600мс) |
|
| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.80 / 0.94 / 42 | базовое трение / стартовая вязкость / длительность (~700мс) |
|
||||||
|
| `BLOOM_MS` / `BLOOM_STAGGER` | 900 / 40 | длительность CSS-разлёта / задержка между узлами (каскад) |
|
||||||
| `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки |
|
| `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки |
|
||||||
| `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса |
|
| `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса |
|
||||||
| `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) |
|
| `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) |
|
||||||
|
|
||||||
|
Прочее (вшито в код): Ghost-слой — 1000мс; CSS-переход фильтра — 300мс; пульсация сияния — 3.6с;
|
||||||
|
прорастание линий привязано к прогрессу разлёта узла (а не к отдельному таймеру).
|
||||||
|
|
||||||
## Локальный запуск / проверка
|
## Локальный запуск / проверка
|
||||||
- Dev-сервер: `.claude/shine-ui-dev-server.cjs` (Node, порт 7321, SPA-fallback + инжект `<base href="/">`).
|
- Dev-сервер: `.claude/shine-ui-dev-server.cjs` (Node, порт 7321, SPA-fallback + инжект `<base href="/">`).
|
||||||
- Лаборатория (без бэкенда): `http://localhost:7321/network-view/lab` — мок `networkGraphUsers`,
|
- Лаборатория (без бэкенда): `http://localhost:7321/network-view/lab` — мок `networkGraphUsers`,
|
||||||
|
|||||||
@ -308,7 +308,16 @@ const NETWORK_NAMES = {
|
|||||||
ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий',
|
ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий',
|
||||||
oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл',
|
oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл',
|
||||||
};
|
};
|
||||||
const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg']);
|
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) {
|
function networkConn(login, relationType, connectionStrength) {
|
||||||
return {
|
return {
|
||||||
@ -316,6 +325,7 @@ function networkConn(login, relationType, connectionStrength) {
|
|||||||
login,
|
login,
|
||||||
name: NETWORK_NAMES[login] || login,
|
name: NETWORK_NAMES[login] || login,
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
photo: NETWORK_PHOTOS[login] || null,
|
||||||
relationType,
|
relationType,
|
||||||
connectionStrength,
|
connectionStrength,
|
||||||
hasOwnConnections: true,
|
hasOwnConnections: true,
|
||||||
@ -330,6 +340,7 @@ function networkPerson(login, connections) {
|
|||||||
login,
|
login,
|
||||||
name: NETWORK_NAMES[login] || login,
|
name: NETWORK_NAMES[login] || login,
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
photo: NETWORK_PHOTOS[login] || null,
|
||||||
status: NETWORK_SHINING.has(login) ? 'shining' : '',
|
status: NETWORK_SHINING.has(login) ? 'shining' : '',
|
||||||
},
|
},
|
||||||
connections,
|
connections,
|
||||||
|
|||||||
@ -563,6 +563,9 @@ export function render({ navigate, route }) {
|
|||||||
// Панель фильтров слоёв (оверлей под шапкой)
|
// Панель фильтров слоёв (оверлей под шапкой)
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar';
|
filterBar.className = 'fg-filter-bar';
|
||||||
|
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
||||||
|
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
||||||
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
FILTER_ORDER.forEach((key) => {
|
FILTER_ORDER.forEach((key) => {
|
||||||
const chip = document.createElement('button');
|
const chip = document.createElement('button');
|
||||||
chip.type = 'button';
|
chip.type = 'button';
|
||||||
|
|||||||
@ -14,7 +14,9 @@
|
|||||||
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё):
|
// Движок работает с нейтральной моделью (ниже buildModelFromTz конвертирует форму ТЗ в неё):
|
||||||
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
|
||||||
|
|
||||||
import { renderUserAvatar } from '../../components/avatar-image.js';
|
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
|
||||||
|
|
||||||
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
// --- Параметры физики и анимации ---------------------------------------------
|
// --- Параметры физики и анимации ---------------------------------------------
|
||||||
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
|
||||||
@ -24,14 +26,16 @@ const K_FOCUS = 0.12; // мягкая пружина фокуса к
|
|||||||
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
|
||||||
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
const CHARGE_START_FACTOR = 0.45; // доля отталкивания в момент «рождения» из центра (без паники)
|
||||||
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
const MIN_DIST = 40; // минимальная дистанция для расчёта отталкивания
|
||||||
const FRICTION = 0.82; // базовое затухание скорости (свободное покачивание)
|
const FRICTION = 0.80; // базовое затухание (после транзита — лёгкое упругое покачивание)
|
||||||
const FRICTION_BOOST = 0.92; // максимальная вязкость в первые ~600мс после перестроения (гасит «взрыв»)
|
const FRICTION_BOOST = 0.94; // «гелевая» вязкость в первые ~700мс после перестроения (гасит «взрыв»)
|
||||||
const BOOST_FRAMES = 36; // длительность затухающего boost'а вязкости (~600мс @60fps)
|
const BOOST_FRAMES = 42; // длительность затухающего boost'а вязкости (~700мс @60fps)
|
||||||
const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| для жёсткой заморозки графа
|
const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| для жёсткой заморозки графа
|
||||||
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
|
||||||
const EDGE_LERP = 0.25; // догон концов линии за узлом за кадр (эффект натянутой резинки)
|
const EDGE_LERP = 0.25; // догон концов линии за узлом за кадр (эффект натянутой резинки)
|
||||||
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
const PAN_FRICTION = 0.93; // трение инерционного скролла карты
|
||||||
const TWEEN_MS = 560; // длительность анимации центрирования
|
const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк)
|
||||||
|
const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
|
||||||
|
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
|
||||||
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
|
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.48–1.52x)
|
||||||
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
|
||||||
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
|
||||||
@ -46,15 +50,59 @@ const RELATION_COLORS = {
|
|||||||
contact: 'rgba(170, 190, 220, 0.7)',
|
contact: 'rgba(170, 190, 220, 0.7)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
|
||||||
|
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
|
||||||
|
|
||||||
|
// Яркий неон сияния — в него уходит градиент связи к «сияющему» узлу (совпадает со свечением аватарки).
|
||||||
|
const SHINE_EDGE_NEON = 'rgba(150, 245, 255, 0.95)';
|
||||||
|
|
||||||
function easeOutCubic(t) {
|
function easeOutCubic(t) {
|
||||||
const x = 1 - t;
|
const x = 1 - t;
|
||||||
return 1 - x * x * x;
|
return 1 - x * x * x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Решатель кубической кривой Безье (CSS cubic-bezier): прогресс x → значение y.
|
||||||
|
function cubicBezier(x1, y1, x2, y2) {
|
||||||
|
const cx = 3 * x1;
|
||||||
|
const bx = 3 * (x2 - x1) - cx;
|
||||||
|
const ax = 1 - cx - bx;
|
||||||
|
const cy = 3 * y1;
|
||||||
|
const by = 3 * (y2 - y1) - cy;
|
||||||
|
const ay = 1 - cy - by;
|
||||||
|
const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
|
||||||
|
const sampleY = (t) => ((ay * t + by) * t + cy) * t;
|
||||||
|
const dX = (t) => (3 * ax * t + 2 * bx) * t + cx;
|
||||||
|
return (x) => {
|
||||||
|
let t = x;
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const d = dX(t);
|
||||||
|
if (Math.abs(d) < 1e-6) break;
|
||||||
|
t -= (sampleX(t) - x) / d;
|
||||||
|
}
|
||||||
|
return sampleY(Math.max(0, Math.min(1, t)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Премиальная «вязкая» кривая для разлёта узлов (быстрый старт → очень мягкая посадка).
|
||||||
|
const EASE_BLOOM = cubicBezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
function relationColor(relationType) {
|
function relationColor(relationType) {
|
||||||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
|
||||||
|
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
|
||||||
|
function ensureShineFilter() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById('fg-shine-glow')) return;
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
svg.setAttribute('width', '0');
|
||||||
|
svg.setAttribute('height', '0');
|
||||||
|
svg.style.position = 'absolute';
|
||||||
|
svg.innerHTML = '<defs><filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" '
|
||||||
|
+ 'color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></filter></defs>';
|
||||||
|
document.body.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
|
||||||
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
|
||||||
function spreadAngle(index, total) {
|
function spreadAngle(index, total) {
|
||||||
@ -86,7 +134,7 @@ function hash01(str) {
|
|||||||
*/
|
*/
|
||||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) {
|
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) {
|
||||||
// Слои DOM
|
// Слои DOM
|
||||||
const edgesSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||||||
edgesSvg.setAttribute('class', 'fg-edges');
|
edgesSvg.setAttribute('class', 'fg-edges');
|
||||||
const world = document.createElement('div');
|
const world = document.createElement('div');
|
||||||
world.className = 'fg-world';
|
world.className = 'fg-world';
|
||||||
@ -95,6 +143,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const reticle = document.createElement('div');
|
const reticle = document.createElement('div');
|
||||||
reticle.className = 'fg-reticle';
|
reticle.className = 'fg-reticle';
|
||||||
stage.append(edgesSvg, world, reticle);
|
stage.append(edgesSvg, world, reticle);
|
||||||
|
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
|
||||||
|
|
||||||
// Состояние камеры (панорамирование)
|
// Состояние камеры (панорамирование)
|
||||||
let camX = 0;
|
let camX = 0;
|
||||||
@ -118,14 +167,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла)
|
// Точка, из которой новый фокус «влетает» в центр (мировые координаты кликнутого узла)
|
||||||
let pendingFocusOrigin = null;
|
let pendingFocusOrigin = null;
|
||||||
|
|
||||||
// Прогресс «прорастания» линий 0→1 (1 = полностью вычерчены)
|
|
||||||
let edgeGrowth = 1;
|
|
||||||
|
|
||||||
// Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра.
|
// Затухающий «boost» вязкости после перестроения (1→0): гасит энергию «взрыва» из центра.
|
||||||
let boost = 0;
|
let boost = 0;
|
||||||
let frictionNow = FRICTION;
|
let frictionNow = FRICTION;
|
||||||
let chargeNow = CHARGE;
|
let chargeNow = CHARGE;
|
||||||
|
|
||||||
|
// Режим CSS-bloom: узлы разлетаются нативными CSS-переходами (компоновщик, без JS-физики),
|
||||||
|
// цикл только перерисовывает лучи вслед за узлами. Завершается по таймеру.
|
||||||
|
let cssBloom = false;
|
||||||
|
let cssBloomTimer = 0;
|
||||||
|
let cssBloomKind = 'bloom'; // 'bloom' (каскадный разлёт) | 'filter' (фиксация на равномерных углах)
|
||||||
|
|
||||||
// Инерция панорамирования (kinematic panning)
|
// Инерция панорамирования (kinematic panning)
|
||||||
let panVelX = 0;
|
let panVelX = 0;
|
||||||
let panVelY = 0;
|
let panVelY = 0;
|
||||||
@ -193,11 +245,32 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
targetOpacity: 1,
|
targetOpacity: 1,
|
||||||
bloom: false,
|
bloom: false,
|
||||||
|
edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0)
|
||||||
el,
|
el,
|
||||||
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
|
dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Аватар из прямого URL-фото (тестовые данные лаборатории). Структура — как у renderUserAvatar
|
||||||
|
// (переиспользуем CSS .avatar/.node-dot), фолбэк на инициалы при ошибке загрузки (офлайн).
|
||||||
|
function buildPhotoAvatar(src) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'avatar avatar-image node-dot';
|
||||||
|
const fb = document.createElement('span');
|
||||||
|
fb.className = 'avatar-fallback';
|
||||||
|
fb.textContent = buildAvatarInitials({ login: src.login || String(src.id), firstName: src.name || '' });
|
||||||
|
wrap.append(fb);
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.onload = () => wrap.classList.add('has-image');
|
||||||
|
img.onerror = () => { img.remove(); }; // нет сети — остаются инициалы
|
||||||
|
img.src = src.photo;
|
||||||
|
wrap.append(img);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
@ -221,7 +294,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
el.dataset.nodeId = String(src.id);
|
el.dataset.nodeId = String(src.id);
|
||||||
|
|
||||||
const avatar = renderUserAvatar({
|
// тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы)
|
||||||
|
const avatar = src.photo
|
||||||
|
? buildPhotoAvatar(src)
|
||||||
|
: renderUserAvatar({
|
||||||
login: src.login || src.name || String(src.id),
|
login: src.login || src.name || String(src.id),
|
||||||
firstName: src.name || '',
|
firstName: src.name || '',
|
||||||
avatar: src.avatar || null,
|
avatar: src.avatar || null,
|
||||||
@ -295,51 +371,83 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
|
|
||||||
const focusLogin = String(focus.login || '').toLowerCase();
|
const focusLogin = String(focus.login || '').toLowerCase();
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const defs = [];
|
||||||
|
let gi = 0;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n === focus) continue;
|
if (n === focus) continue;
|
||||||
if (n.hidden) continue;
|
// скрытый фильтром узел: рисуем луч пока он гаснет (живая прозрачность > 0), затем пропускаем
|
||||||
|
const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1;
|
||||||
|
if (n.hidden && nodeOpacity <= 0.02) continue;
|
||||||
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
||||||
const nx = tx(n);
|
const nx = tx(n);
|
||||||
const ny = ty(n);
|
const ny = ty(n);
|
||||||
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
|
||||||
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
|
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
|
||||||
|
|
||||||
const dx = nx - fx;
|
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке
|
||||||
const dy = ny - fy;
|
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
|
||||||
|
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
|
||||||
|
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
|
||||||
|
const ex = growing ? (centerX + camX + n.bfx) : nx;
|
||||||
|
const ey = growing ? (centerY + camY + n.bfy) : ny;
|
||||||
|
const dx = ex - fx;
|
||||||
|
const dy = ey - fy;
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const ux = dx / len;
|
const ux = dx / len;
|
||||||
const uy = dy / len;
|
const uy = dy / len;
|
||||||
const nr = n.dotRadius * n.scale + 4;
|
const nr = n.dotRadius * n.scale + 4;
|
||||||
// концы линии — у краёв кружков (по истинной позиции)
|
// концы линии — у краёв кружков
|
||||||
const x1 = fx + ux * fr;
|
const x1 = fx + ux * fr;
|
||||||
const y1 = fy + uy * fr;
|
const y1 = fy + uy * fr;
|
||||||
const x2 = nx - ux * nr;
|
const x2 = ex - ux * nr;
|
||||||
const y2 = ny - uy * nr;
|
const y2 = ey - uy * nr;
|
||||||
// контрольная точка кривой Безье: постоянный лёгкий изгиб (провисание) перпендикулярно
|
// контрольная точка кривой Безье: постоянная изящная дуга (перпендикуляр) +
|
||||||
// линии + динамика от запаздывания (при движении узла нить выгибается сильнее)
|
// прогиб НАЗАД против вектора скорости узла (резиновый жгут); в покое — идеальная дуга
|
||||||
const mx = (x1 + x2) / 2;
|
const mx = (x1 + x2) / 2;
|
||||||
const my = (y1 + y2) / 2;
|
const my = (y1 + y2) / 2;
|
||||||
const segLen0 = Math.hypot(x2 - x1, y2 - y1);
|
const segLen0 = Math.hypot(x2 - x1, y2 - y1);
|
||||||
// изгиб строго перпендикулярный: заметная постоянная дуга (≈7–22px) +
|
const baseBow = Math.max(7, Math.min(20, segLen0 * 0.12)); // постоянная дуга
|
||||||
// динамика от скорости узла → при движении нить выгибается, как натянутая резина
|
|
||||||
const speed = Math.hypot(n.vx, n.vy);
|
const speed = Math.hypot(n.vx, n.vy);
|
||||||
const bow = Math.max(7, Math.min(22, segLen0 * 0.13)) + Math.min(16, speed * 1.2);
|
const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости
|
||||||
const cpx = mx + (-uy) * bow * 2; // CP даёт середину Q-кривой = M + perp*bow
|
const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения
|
||||||
const cpy = my + ux * bow * 2;
|
const invY = speed > 0.01 ? -n.vy / speed : 0;
|
||||||
// минимализм: тонкие (1.3–1.8px), полупрозрачные линии — без «энергетических лучей»
|
// желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости
|
||||||
const w = 1.3 + n.strength * 0.5;
|
const desX = mx + (-uy) * baseBow + invX * lag;
|
||||||
// прорастание: длину пути приближаем хордой, dash-offset → 0
|
const desY = my + ux * baseBow + invY * lag;
|
||||||
let dash = '';
|
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
|
||||||
if (edgeGrowth < 1) {
|
const cpy = 2 * desY - my;
|
||||||
const segLen = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy)) || 1;
|
// ТОНКАЯ изящная дуга: одинарная квадратичная кривая Безье, лёгкий градиентный штрих.
|
||||||
dash = ` stroke-dasharray="${segLen.toFixed(1)}" stroke-dashoffset="${(segLen * (1 - edgeGrowth)).toFixed(1)}"`;
|
// Обычная связь — неон у центра → цвет роли у узла. Связь к «СИЯЮЩЕМУ» — ярче, уходит в
|
||||||
}
|
// неон сияния и МОНОЛИТНО светится (статичный drop-shadow через класс .fg-edge-shine).
|
||||||
parts.push(
|
const shine = Boolean(n.shining) && !n.hidden;
|
||||||
`<path d="M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}" `
|
const gid = `fg-grad-${gi}`;
|
||||||
+ `fill="none" stroke="${relationColor(n.relationType)}" stroke-opacity="0.42" stroke-width="${w.toFixed(2)}" stroke-linecap="round"${dash} />`
|
gi += 1;
|
||||||
|
const tipColor = shine ? SHINE_EDGE_NEON : relationColor(n.relationType);
|
||||||
|
const baseStop = shine ? 0.85 : 0.5;
|
||||||
|
const tipStop = shine ? 0.7 : 0.14;
|
||||||
|
defs.push(
|
||||||
|
`<linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}">`
|
||||||
|
+ `<stop offset="0" stop-color="${FOCUS_NEON}" stop-opacity="${baseStop}"/>`
|
||||||
|
+ `<stop offset="1" stop-color="${tipColor}" stop-opacity="${tipStop}"/></linearGradient>`
|
||||||
);
|
);
|
||||||
|
const sw = (shine ? 1.7 + n.strength * 0.8 : 1.3 + n.strength * 0.9).toFixed(2); // тонко
|
||||||
|
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||||
|
// прозрачность луча = живая прозрачность узла (гаснет вместе с узлом при фильтре/уходе)
|
||||||
|
const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
|
||||||
|
// ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset уводим от длины к 0 по мере
|
||||||
|
// разлёта (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
|
||||||
|
let dashAttr = '';
|
||||||
|
if (growing) {
|
||||||
|
const finalD = Math.hypot(n.bfx, n.bfy) || 1;
|
||||||
|
const curD = Math.hypot(n.x, n.y);
|
||||||
|
const growP = Math.max(0, Math.min(1, curD / finalD));
|
||||||
|
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
|
||||||
|
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
||||||
}
|
}
|
||||||
edgesSvg.innerHTML = parts.join('');
|
const cls = shine ? ' class="fg-edge-shine"' : ''; // монолитное неоновое свечение (drop-shadow)
|
||||||
|
parts.push(`<path${cls} d="${d}" fill="none" stroke="url(#${gid})" stroke-width="${sw}" stroke-linecap="round"${op}${dashAttr} />`);
|
||||||
|
}
|
||||||
|
edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateReticle() {
|
function updateReticle() {
|
||||||
@ -413,18 +521,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Плавное приближение масштаба/прозрачности к целям (bloom новых, рост/уменьшение при смене роли).
|
// Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»).
|
||||||
function advanceVisual() {
|
function advanceVisual() {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
n.scale += (n.targetScale - n.scale) * 0.2;
|
n.scale += (n.targetScale - n.scale) * 0.2;
|
||||||
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
|
n.opacity += (n.targetOpacity - n.opacity) * 0.2;
|
||||||
|
// линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить
|
||||||
|
if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не «успокоились» ли ещё визуальные параметры (для условия заморозки).
|
// Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки).
|
||||||
function visualSettling() {
|
function visualSettling() {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01) return true;
|
if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -476,28 +586,41 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
wake();
|
wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Универсальный твин (физика выключена → ноль тряски). Поддерживает длительность, кривую и
|
||||||
|
// поканальную задержку (каскад) + рост линий. Используется для bloom-разлёта, фильтра, центрирования.
|
||||||
function stepTween(ts) {
|
function stepTween(ts) {
|
||||||
if (!tween.startTs) tween.startTs = ts;
|
if (!tween.startTs) tween.startTs = ts;
|
||||||
const raw = Math.min(1, (ts - tween.startTs) / TWEEN_MS);
|
const dur = tween.dur || TWEEN_MS;
|
||||||
const t = easeOutCubic(raw);
|
const ease = tween.ease || easeOutCubic;
|
||||||
|
const elapsed = ts - tween.startTs;
|
||||||
|
let allDone = true;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const a = tween.from.get(n.id);
|
const a = tween.from.get(n.id);
|
||||||
const b = tween.to.get(n.id);
|
const b = tween.to.get(n.id);
|
||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
|
let raw = (elapsed - (a.delay || 0)) / dur;
|
||||||
|
if (raw < 0) raw = 0; // узел ещё не «выпущен» — держим в стартовой точке
|
||||||
|
if (raw < 1) allDone = false;
|
||||||
|
raw = Math.min(1, raw);
|
||||||
|
const t = ease(raw);
|
||||||
n.x = a.x + (b.x - a.x) * t;
|
n.x = a.x + (b.x - a.x) * t;
|
||||||
n.y = a.y + (b.y - a.y) * t;
|
n.y = a.y + (b.y - a.y) * t;
|
||||||
n.scale = a.scale + (b.scale - a.scale) * t;
|
n.scale = a.scale + (b.scale - a.scale) * t;
|
||||||
const ao = a.opacity ?? 1;
|
const ao = a.opacity ?? 1;
|
||||||
const bo = b.opacity ?? 1;
|
const bo = b.opacity ?? 1;
|
||||||
n.opacity = ao + (bo - ao) * t;
|
n.opacity = ao + (bo - ao) * t;
|
||||||
|
n.lerpX = n.x; n.lerpY = n.y;
|
||||||
|
if (b.grow) n.edgeGrow = raw; // линия «вытекает» по прогрессу своего узла
|
||||||
}
|
}
|
||||||
camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * t;
|
const camT = ease(Math.min(1, elapsed / dur));
|
||||||
camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * t;
|
camX = tween.camFrom.x + (tween.camTo.x - tween.camFrom.x) * camT;
|
||||||
|
camY = tween.camFrom.y + (tween.camTo.y - tween.camFrom.y) * camT;
|
||||||
applyWorldTransform();
|
applyWorldTransform();
|
||||||
if (raw >= 1) {
|
if (allDone) {
|
||||||
|
const wasBloom = tween.idleBoost;
|
||||||
tween = null; // твин завершён
|
tween = null; // твин завершён
|
||||||
// синхронизируем цели визуала с текущими, чтобы advanceVisual не «откатил» (важно для фильтра)
|
for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; n.edgeGrow = 1; }
|
||||||
for (const n of nodes) { n.targetScale = n.scale; n.targetOpacity = n.opacity; }
|
if (wasBloom) boost = 1; // в покое — лёгкое «гель»-демпфированное упругое покачивание
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,12 +632,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
|
// Фильтр слоёв: pred(node) → показывать ли узел. Фокус всегда виден.
|
||||||
// Видимые перераспределяются по орбите, скрытые плавно гаснут (scale↓ + opacity→0).
|
// Перестроение идёт нативными CSS-переходами (компоновщик): работает даже когда rAF-цикл
|
||||||
|
// троттлится в простое (иначе граф «не перестраивался» бы). Скрытые узлы плавно гаснут НА МЕСТЕ
|
||||||
|
// (scale 0.8 + opacity 0 за 300мс), видимые плавно переплывают на равномерные углы орбиты;
|
||||||
|
// лучи скрываемых гаснут вместе с ними (renderEdges читает живую прозрачность из DOM).
|
||||||
|
// По завершении — жёсткая фиксация на этих углах БЕЗ физики (ноль тряски, идеальный sleep).
|
||||||
function setFilter(predicate) {
|
function setFilter(predicate) {
|
||||||
const pred = typeof predicate === 'function' ? predicate : () => true;
|
const pred = typeof predicate === 'function' ? predicate : () => true;
|
||||||
const from = new Map();
|
if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; }
|
||||||
const to = new Map();
|
cancelTween(); // на случай активного JS-твина центрирования — отдаём управление CSS-переходу
|
||||||
nodes.forEach((n) => from.set(n.id, { x: n.x, y: n.y, scale: n.scale, opacity: n.opacity }));
|
|
||||||
|
|
||||||
const visiblePeers = [];
|
const visiblePeers = [];
|
||||||
nodes.forEach((n) => {
|
nodes.forEach((n) => {
|
||||||
@ -525,8 +651,19 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (!n.hidden) visiblePeers.push(n);
|
if (!n.hidden) visiblePeers.push(n);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FILTER_MS = 300;
|
||||||
|
const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`;
|
||||||
|
// применяем целевое состояние как CSS-переход; финал кэшируем в bfx/bfy/bfs/bfo для endCssBloom
|
||||||
|
const apply = (n, x, y, s, o) => {
|
||||||
|
n.bfx = x; n.bfy = y; n.bfs = s; n.bfo = o;
|
||||||
|
n.el.style.transition = `transform ${FILTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${FILTER_MS}ms ease`;
|
||||||
|
n.el.style.transform = tf(x, y, s);
|
||||||
|
n.el.style.opacity = String(o);
|
||||||
|
n.el.style.pointerEvents = o <= 0.01 ? 'none' : '';
|
||||||
|
};
|
||||||
|
|
||||||
const focus = nodes.find((n) => n.isFocus);
|
const focus = nodes.find((n) => n.isFocus);
|
||||||
if (focus) to.set(focus.id, { x: 0, y: 0, scale: FOCUS_SCALE, opacity: 1 });
|
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
|
||||||
|
|
||||||
visiblePeers.forEach((n, i) => {
|
visiblePeers.forEach((n, i) => {
|
||||||
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
|
||||||
@ -534,17 +671,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
n.tx = Math.cos(n.angle) * n.targetR;
|
n.tx = Math.cos(n.angle) * n.targetR;
|
||||||
n.ty = Math.sin(n.angle) * n.targetR;
|
n.ty = Math.sin(n.angle) * n.targetR;
|
||||||
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
|
||||||
to.set(n.id, { x: n.tx, y: n.ty, scale: sc, opacity: 1 });
|
apply(n, n.tx, n.ty, sc, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach((n) => {
|
nodes.forEach((n) => {
|
||||||
if (n.isFocus || !n.hidden) return;
|
if (n.isFocus || !n.hidden) return;
|
||||||
// скрытые: подтягиваем к центру и гасим
|
// скрытые: растворяются ПРЯМО НА МЕСТЕ (scale 0.8 + opacity 0 за 300мс) — мягкий фейд, без «вылетов»
|
||||||
to.set(n.id, { x: n.x * 0.35, y: n.y * 0.35, scale: 0.2, opacity: 0 });
|
apply(n, n.x, n.y, 0.8, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// фильтр не двигает камеру (в отличие от центрирования)
|
// режим CSS-перехода: цикл лишь ведёт лучи за узлами; по таймеру — фиксация без физики
|
||||||
tween = { startTs: 0, from, to, camFrom: { x: camX, y: camY }, camTo: { x: camX, y: camY } };
|
cssBloom = true;
|
||||||
|
cssBloomKind = 'filter';
|
||||||
|
renderEdges();
|
||||||
|
cssBloomTimer = window.setTimeout(endCssBloom, FILTER_MS + 60);
|
||||||
wake();
|
wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -568,6 +708,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
function tick(ts) {
|
function tick(ts) {
|
||||||
rafId = 0;
|
rafId = 0;
|
||||||
|
|
||||||
|
// режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики)
|
||||||
|
if (cssBloom) {
|
||||||
|
syncPositionsFromDOM();
|
||||||
|
renderEdges();
|
||||||
|
updateReticle();
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// инерция панорамирования (kinematic): камера докатывается с трением
|
// инерция панорамирования (kinematic): камера докатывается с трением
|
||||||
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
const panActive = !dragging && (Math.abs(panVelX) > 0.15 || Math.abs(panVelY) > 0.15);
|
||||||
if (panActive) {
|
if (panActive) {
|
||||||
@ -581,10 +730,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
panVelY = 0;
|
panVelY = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (edgeGrowth < 1) edgeGrowth = Math.min(1, edgeGrowth + 0.07); // прорастание линий ~15 кадров
|
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
||||||
|
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
||||||
// динамическая вязкость: первые ~400мс после перестроения трение выше (0.90→0.82),
|
|
||||||
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту мягко
|
|
||||||
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
|
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
|
||||||
chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost);
|
chargeNow = CHARGE * (1 - (1 - CHARGE_START_FACTOR) * boost);
|
||||||
if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES);
|
if (boost > 0) boost = Math.max(0, boost - 1 / BOOST_FRAMES);
|
||||||
@ -601,7 +748,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
renderAll();
|
renderAll();
|
||||||
|
|
||||||
const lerpSettling = nodes.some((n) => Math.abs(n.x - n.lerpX) + Math.abs(n.y - n.lerpY) > 0.5);
|
const lerpSettling = nodes.some((n) => Math.abs(n.x - n.lerpX) + Math.abs(n.y - n.lerpY) > 0.5);
|
||||||
if (tween || dragging || panActive || edgeGrowth < 1 || boost > 0 || totalV > SLEEP_V || lerpSettling || visualSettling()) {
|
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || lerpSettling || visualSettling()) {
|
||||||
schedule();
|
schedule();
|
||||||
} else {
|
} else {
|
||||||
freezeGraph(); // система успокоилась — замираем
|
freezeGraph(); // система успокоилась — замираем
|
||||||
@ -724,24 +871,22 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Жизненный цикл узлов (diffing) ----------------------------------------
|
// --- Жизненный цикл узлов (diffing) ----------------------------------------
|
||||||
// Ghost-слой = СНИМОК всего старого графа (узлы + линии) на полноэкранном слое.
|
// Ghost-слой = СНИМОК старых АВАТАРОК (без линий!) на полноэкранном слое. Линии в шлейф НЕ
|
||||||
// Клон застывает СТРОГО НА МЕСТЕ (полноэкранный overlay → координаты не сбрасываются),
|
// копируем намеренно: старые связи должны исчезать мгновенно вместе с перерисовкой графа, а не
|
||||||
// плавно уменьшается (scale 1→0.7) и растворяется (opacity 0.4→0) за 500мс — красивый
|
// висеть «ошмётками» секунду. Клон застывает на месте и лениво тает (scale 1→0.7 + opacity→0)
|
||||||
// шлейф истории перехода, — после чего полностью удаляется из DOM.
|
// за 1000мс — мягкий породистый шлейф истории, — затем удаляется из DOM (строго через 1000мс).
|
||||||
function spawnGhost() {
|
function spawnGhost() {
|
||||||
if (!world.childElementCount) return;
|
if (!world.childElementCount) return;
|
||||||
const ghost = document.createElement('div');
|
const ghost = document.createElement('div');
|
||||||
ghost.className = 'fg-ghost-layer';
|
ghost.className = 'fg-ghost-layer';
|
||||||
const edgesClone = edgesSvg.cloneNode(true); // .fg-edges (inset:0) → линии совпадают по координатам
|
const worldClone = world.cloneNode(true); // .fg-world (центр) → только узлы на своих местах
|
||||||
edgesClone.style.opacity = ''; // снимаем возможный inline-fade, слой задаёт прозрачность сам
|
|
||||||
const worldClone = world.cloneNode(true); // .fg-world (центр) → узлы на своих местах
|
|
||||||
worldClone.style.transform = world.style.transform || '';
|
worldClone.style.transform = world.style.transform || '';
|
||||||
ghost.append(edgesClone, worldClone);
|
ghost.append(worldClone);
|
||||||
stage.insertBefore(ghost, edgesSvg); // позади живых слоёв
|
stage.insertBefore(ghost, edgesSvg); // позади живых слоёв
|
||||||
void ghost.offsetWidth; // рефлоу для запуска CSS-перехода
|
void ghost.offsetWidth; // рефлоу для запуска CSS-перехода
|
||||||
ghost.style.transform = 'scale(0.7)';
|
ghost.style.transform = 'scale(0.7)';
|
||||||
ghost.style.opacity = '0';
|
ghost.style.opacity = '0';
|
||||||
window.setTimeout(() => ghost.remove(), 800);
|
window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс
|
||||||
}
|
}
|
||||||
|
|
||||||
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
|
// Импульс центрального кольца — подтверждение «захвата» нового фокуса.
|
||||||
@ -752,6 +897,48 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
|
window.setTimeout(() => reticle.classList.remove('is-pulse'), 620);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM,
|
||||||
|
// чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками.
|
||||||
|
function syncPositionsFromDOM() {
|
||||||
|
const sr = stage.getBoundingClientRect();
|
||||||
|
for (const n of nodes) {
|
||||||
|
const dot = n.el.querySelector('.node-dot') || n.el;
|
||||||
|
const r = dot.getBoundingClientRect();
|
||||||
|
n.x = (r.left + r.width / 2) - sr.left - centerX - camX;
|
||||||
|
n.y = (r.top + r.height / 2) - sr.top - centerY - camY;
|
||||||
|
n.lerpX = n.x; n.lerpY = n.y;
|
||||||
|
// живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой
|
||||||
|
const o = parseFloat(getComputedStyle(n.el).opacity);
|
||||||
|
if (Number.isFinite(o)) n.opacity = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершение CSS-bloom (по таймеру — гарантированно, даже при троттлинге rAF):
|
||||||
|
// снимаем переходы, ставим узлы в финал и включаем лёгкую физику покачивания в покое.
|
||||||
|
function endCssBloom() {
|
||||||
|
cssBloomTimer = 0;
|
||||||
|
if (!cssBloom) return;
|
||||||
|
cssBloom = false;
|
||||||
|
for (const n of nodes) {
|
||||||
|
n.el.style.transition = '';
|
||||||
|
const fo = (typeof n.bfo === 'number') ? n.bfo : 1; // финальная прозрачность (0 — скрыт фильтром)
|
||||||
|
n.x = n.bfx; n.y = n.bfy; n.lerpX = n.x; n.lerpY = n.y;
|
||||||
|
n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo;
|
||||||
|
n.vx = 0; n.vy = 0; n.edgeGrow = 1;
|
||||||
|
}
|
||||||
|
if (cssBloomKind === 'filter') {
|
||||||
|
// ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся
|
||||||
|
// строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep.
|
||||||
|
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
||||||
|
freezeGraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boost = 1; // BLOOM: мягкое «гель»-демпфированное упругое покачивание в покое (0.94→0.80)
|
||||||
|
renderNodes();
|
||||||
|
renderEdges();
|
||||||
|
wake();
|
||||||
|
}
|
||||||
|
|
||||||
// Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний:
|
// Перестроение графа с НЕПРЕРЫВНОСТЬЮ состояний:
|
||||||
// • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита);
|
// • общий узел (тот же id) — не пересоздаём, плавно перелетает на новое место (роль/орбита);
|
||||||
// • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1);
|
// • новый узел — «расцветает» (bloom) из позиции нового фокуса (scale 0→1, opacity 0→1);
|
||||||
@ -761,75 +948,86 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const newIds = new Set(specs.map((s) => s.id));
|
const newIds = new Set(specs.map((s) => s.id));
|
||||||
const oldById = new Map(nodes.map((n) => [String(n.id), n]));
|
const oldById = new Map(nodes.map((n) => [String(n.id), n]));
|
||||||
|
|
||||||
// точка рождения новых узлов = текущая позиция нового фокуса (откуда он «исходит»)
|
// старый узел нового фокуса (если был) — фокус глайдит из его позиции
|
||||||
const focusOld = oldById.get(String(newFocusId));
|
const focusOld = oldById.get(String(newFocusId));
|
||||||
const originX = focusOld ? focusOld.x : (pendingFocusOrigin ? pendingFocusOrigin.x : 0);
|
|
||||||
const originY = focusOld ? focusOld.y : (pendingFocusOrigin ? pendingFocusOrigin.y : 0);
|
|
||||||
|
|
||||||
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
// снимок всего старого графа → красивый шлейф; затем убираем «ушедшие» узлы из живого мира
|
||||||
spawnGhost();
|
spawnGhost();
|
||||||
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
nodes.forEach((n) => { if (!newIds.has(String(n.id))) n.el.remove(); });
|
||||||
|
|
||||||
focusId = String(newFocusId);
|
focusId = String(newFocusId);
|
||||||
edgeGrowth = 0; // линии к новым узлам прорастают из центра
|
if (cssBloomTimer) { window.clearTimeout(cssBloomTimer); cssBloomTimer = 0; }
|
||||||
boost = 1; // включаем повышенную вязкость на ~400мс (гасим энергию разлёта)
|
|
||||||
|
const tf = (x, y, s) => `translate(calc(${x.toFixed(1)}px - 50%), calc(${y.toFixed(1)}px - 50%)) scale(${s})`;
|
||||||
|
let order = 0;
|
||||||
|
let maxDelay = 0;
|
||||||
|
const blooms = [];
|
||||||
|
|
||||||
const fresh = [];
|
|
||||||
let bloomOrder = 0;
|
|
||||||
nodes = specs.map((spec) => {
|
nodes = specs.map((spec) => {
|
||||||
const old = oldById.get(spec.id);
|
const oldNode = oldById.get(spec.id);
|
||||||
if (old && old.dotOnly === spec.dotOnly) {
|
let node;
|
||||||
updateNodeRole(old, spec); // непрерывность: тот же DOM, новая цель → перелёт пружиной
|
let isNew;
|
||||||
return old;
|
if (oldNode && oldNode.dotOnly === spec.dotOnly) {
|
||||||
}
|
updateNodeRole(oldNode, spec); // непрерывность: тот же DOM, новая роль/орбита
|
||||||
if (old) old.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
|
node = oldNode; isNew = false;
|
||||||
const node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
|
|
||||||
// периферия «выстреливает» из центрального круга (0,0); смещение вдоль угла даёт направление силам
|
|
||||||
const bx = Math.cos(node.angle) * 14;
|
|
||||||
const by = Math.sin(node.angle) * 14;
|
|
||||||
node.x = node.isFocus ? originX : bx;
|
|
||||||
node.y = node.isFocus ? originY : by;
|
|
||||||
node.lerpX = node.x; node.lerpY = node.y;
|
|
||||||
node.scale = 0.01; node.opacity = 0; node.bloom = true;
|
|
||||||
node.bloomScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (Number(spec.src.tier) >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
|
||||||
if (node.isFocus) {
|
|
||||||
node.targetScale = node.bloomScale; node.targetOpacity = 1; // фокус виден сразу (влетает)
|
|
||||||
} else {
|
} else {
|
||||||
// периферия: держим скрытой в центре и «выстреливаем» по очереди (каскад 40мс)
|
if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
|
||||||
node.hidden = true;
|
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
|
||||||
node.targetScale = 0; node.targetOpacity = 0;
|
isNew = true;
|
||||||
node.bloomOrder = bloomOrder++;
|
|
||||||
fresh.push(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
||||||
|
const finalX = node.isFocus ? 0 : node.tx;
|
||||||
|
const finalY = node.isFocus ? 0 : node.ty;
|
||||||
|
const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE));
|
||||||
|
|
||||||
|
// стартовая точка разлёта
|
||||||
|
let fx; let fy; let fs; let fo; let delay = 0;
|
||||||
|
if (node.isFocus) {
|
||||||
|
if (focusOld) { fx = focusOld.x; fy = focusOld.y; fs = focusOld.scale; fo = 1; } // глайд из старой позиции
|
||||||
|
else if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) {
|
||||||
|
fx = pendingFocusOrigin.x; fy = pendingFocusOrigin.y; fs = 0.6; fo = 1; // влёт из точки клика
|
||||||
|
} else { fx = 0; fy = 0; fs = 0.3; fo = 0; } // первичная инициализация
|
||||||
|
} else if (isNew) {
|
||||||
|
fx = Math.cos(node.angle) * 12; fy = Math.sin(node.angle) * 12; fs = 0.2; fo = 0; // из центрального круга
|
||||||
|
order += 1; delay = order * BLOOM_STAGGER; // каскад (волна)
|
||||||
|
} else {
|
||||||
|
fx = node.x; fy = node.y; fs = node.scale; fo = node.opacity; // непрерывность: с текущего места
|
||||||
|
}
|
||||||
|
|
||||||
|
// финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей)
|
||||||
|
node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1;
|
||||||
|
node.x = fx; node.y = fy; node.lerpX = fx; node.lerpY = fy;
|
||||||
|
node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1;
|
||||||
|
node.hidden = false;
|
||||||
|
// НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0);
|
||||||
|
// переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия).
|
||||||
|
node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1;
|
||||||
|
|
||||||
|
maxDelay = Math.max(maxDelay, delay);
|
||||||
|
blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay });
|
||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
|
|
||||||
// новый фокус «влетает» из точки клика (если кликнули по периферийному узлу)
|
|
||||||
if (pendingFocusOrigin && String(pendingFocusOrigin.id) === focusId) {
|
|
||||||
const f = nodes.find((n) => n.isFocus);
|
|
||||||
if (f && !focusOld) { f.x = pendingFocusOrigin.x; f.y = pendingFocusOrigin.y; f.lerpX = f.x; f.lerpY = f.y; }
|
|
||||||
}
|
|
||||||
pendingFocusOrigin = null;
|
pendingFocusOrigin = null;
|
||||||
|
|
||||||
// каскад: каждый новый узел освобождается из центра через order*40мс → волна
|
camX = 0; camY = 0; applyWorldTransform();
|
||||||
fresh.forEach((node) => {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
node.hidden = false;
|
|
||||||
node.targetScale = node.bloomScale;
|
|
||||||
node.targetOpacity = 1;
|
|
||||||
wake();
|
|
||||||
}, node.bloomOrder * 40);
|
|
||||||
});
|
|
||||||
|
|
||||||
camX = 0;
|
// ПАСС 1: стартовое состояние без перехода
|
||||||
camY = 0;
|
for (const b of blooms) { b.el.style.transition = 'none'; b.el.style.transform = b.start; b.el.style.opacity = String(b.fo); }
|
||||||
applyWorldTransform();
|
void world.offsetWidth; // один форс-рефлоу, чтобы старт «зафиксировался»
|
||||||
renderAll();
|
// ПАСС 2: включаем CSS-переход и ставим финал → компоновщик плавно «по маслу»
|
||||||
// линии: плавно проявляем (старые ушли с призраком)
|
// (работает даже когда JS-rAF троттлится; премиальная вязкая кривая Apple-уровня)
|
||||||
edgesSvg.style.opacity = '0';
|
for (const b of blooms) {
|
||||||
void edgesSvg.offsetWidth;
|
b.el.style.transition = `transform ${BLOOM_MS}ms cubic-bezier(0.16, 1, 0.3, 1) ${b.delay}ms, opacity 700ms ease ${b.delay}ms`;
|
||||||
edgesSvg.style.opacity = '1';
|
b.el.style.transform = b.final;
|
||||||
|
b.el.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами
|
||||||
|
cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost)
|
||||||
|
renderEdges();
|
||||||
pulseReticle();
|
pulseReticle();
|
||||||
|
cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано
|
||||||
wake();
|
wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -880,6 +1078,7 @@ export function buildModelFromTz(tz) {
|
|||||||
login: focus.login || focus.id || '',
|
login: focus.login || focus.id || '',
|
||||||
name: focus.name || '',
|
name: focus.name || '',
|
||||||
avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null,
|
avatar: focus.avatar && focus.avatar !== 'url_to_image' ? focus.avatar : null,
|
||||||
|
photo: focus.photo || null,
|
||||||
relationType: 'self',
|
relationType: 'self',
|
||||||
strength: 1,
|
strength: 1,
|
||||||
shining: String(focus.status || '').toLowerCase() === 'shining',
|
shining: String(focus.status || '').toLowerCase() === 'shining',
|
||||||
@ -891,6 +1090,7 @@ export function buildModelFromTz(tz) {
|
|||||||
login: c.login || c.id || '',
|
login: c.login || c.id || '',
|
||||||
name: c.name || '',
|
name: c.name || '',
|
||||||
avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null,
|
avatar: c.avatar && c.avatar !== 'url_to_image' ? c.avatar : null,
|
||||||
|
photo: c.photo || null,
|
||||||
relationType: c.relationType || 'contact',
|
relationType: c.relationType || 'contact',
|
||||||
strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5,
|
strength: typeof c.connectionStrength === 'number' ? c.connectionStrength : 0.5,
|
||||||
shining: String(c.status || '').toLowerCase() === 'shining',
|
shining: String(c.status || '').toLowerCase() === 'shining',
|
||||||
|
|||||||
@ -12,6 +12,16 @@ import { openNodeMenu } from './node-menu.js';
|
|||||||
|
|
||||||
const START_LOGIN = 'ivan';
|
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() {
|
function helpText() {
|
||||||
return [
|
return [
|
||||||
'Лаборатория карты связей (мок-данные, без сервера).',
|
'Лаборатория карты связей (мок-данные, без сервера).',
|
||||||
@ -19,6 +29,7 @@ function helpText() {
|
|||||||
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
|
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
|
||||||
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
||||||
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
||||||
|
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
||||||
'',
|
'',
|
||||||
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@ -55,6 +66,19 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
|
|
||||||
const model = buildModelFromTz(graphForLogin(START_LOGIN));
|
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);
|
stage.append(header);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
|
|
||||||
@ -64,7 +88,11 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
stage,
|
stage,
|
||||||
model,
|
model,
|
||||||
// тап по узлу — переключаем карту на сеть выбранного человека
|
// тап по узлу — переключаем карту на сеть выбранного человека
|
||||||
onNodeTap: (node) => { graph.setModel(buildModelFromTz(graphForLogin(node.login))); },
|
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}`),
|
onCenterTap: (node) => 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,
|
||||||
@ -77,6 +105,23 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Панель фильтров слоёв — тот же оверлей-стиль (.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 = () => {
|
screen.cleanup = () => {
|
||||||
graph.destroy();
|
graph.destroy();
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
|
|||||||
@ -37,6 +37,12 @@
|
|||||||
transition: opacity 420ms ease; /* плавное появление линий при перестройке */
|
transition: opacity 420ms ease; /* плавное появление линий при перестройке */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Связь к «сияющему» узлу — МОНОЛИТНО светится мягким неоном (тот же приём, что у ободка аватарки):
|
||||||
|
статичная двухслойная тень через drop-shadow. Никакой динамики, бегущих точек и пульсаций. */
|
||||||
|
.fg-edge-shine {
|
||||||
|
filter: drop-shadow(0 0 3px rgba(140, 235, 255, 0.75)) drop-shadow(0 0 6px rgba(110, 225, 255, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
.fg-node {
|
.fg-node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -99,24 +105,52 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35);
|
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 {
|
.fg-node.is-shine .node-dot::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -12px;
|
inset: -12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(130, 235, 255, 0.6) 0%, rgba(130, 235, 255, 0.26) 44%, rgba(130, 235, 255, 0) 76%);
|
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: blur(2px);
|
filter: url(#fg-shine-glow);
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
animation: fg-shine-pulse 2.4s ease-in-out infinite;
|
pointer-events: none;
|
||||||
|
animation: fg-shine-halo 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fg-shine-pulse {
|
/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */
|
||||||
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
@keyframes fg-shine-glow {
|
||||||
50% { transform: scale(1.16); opacity: 0.95; }
|
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) {
|
@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-shine .node-dot::before { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,7 +264,8 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: 0.5; /* стартовая прозрачность шлейфа выше → дольше читается (JS уводит в 0) */
|
opacity: 0.5; /* стартовая прозрачность шлейфа выше → дольше читается (JS уводит в 0) */
|
||||||
transform-origin: 50% 50%;
|
transform-origin: 50% 50%;
|
||||||
transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 800ms cubic-bezier(0.16, 1, 0.3, 1);
|
/* лениво и породисто тает на месте за 1000мс — медленный дорогой шлейф истории перехода */
|
||||||
|
transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Импульс центрального кольца при захвате нового фокуса */
|
/* Импульс центрального кольца при захвате нового фокуса */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user