Связи (pixel-web): этап 2 паутины — hover-превью + collision + zoom + камера-доводчик + синхро-пульс
Доработка режима «Интерактивная паутина» (только лаборатория, deep-режим «Вселенная»): Взаимодействие (по запросу): наведение ≠ клик. - Hover-превью: навёл мышь/палец на узел — его ветка ВРЕМЕННО выплывает; убрал — втягивается. (pointerover/out для мыши, pointerdown/up для пальца → onNodeHover → graph.setHover; флаг hovered). - Фиксация кликом: тап/клик → graph.toggleExpand ставит pinned — ветка остаётся раскрытой и после ухода курсора; повторный тап снимает фиксацию. Эффект = pinned || hovered (expandTargetOf). Этап 2 «Мегамасштаб»: - Collision-расталкивание: раскрытая ветка усиливает отталкивание соседей 1-го уровня пропорционально expandP (EXPAND_REPULSION=2.4) — кластеры разъезжаются, не накладываясь. - Свободный зум: колесо мыши (onWheel) + щипок двумя пальцами (activePointers/pinching), zoom 0.55–2.6 «к точке»; мир — CSS-scale, линии (SVG) пересчитываются в экранных координатах × zoom. - Камера-доводчик: при фиксации ветки, если её веер упирается в край, камера мягко дотягивается (glideCameraTo → camTargetX/Y, lerp CAM_GLIDE_K в tick); любой жест отменяет доводчик. - Синхро-пульс: сияющие/трековые «световоды» дышат толщиной/размытием 3.6с в такт ободку узла. Реальный путь /network-view не затронут: deep-код под tier≥2/hasDeep, hover-колбэк даёт только лаборатория. Ветка экспериментальная (отдельно от pixel-08.06/PR). Бамп client.version → 1.2.144. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04d9d588e8
commit
72dc83daff
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.143
|
client.version=1.2.144
|
||||||
server.version=1.2.127
|
server.version=1.2.128
|
||||||
|
|||||||
@ -88,6 +88,29 @@
|
|||||||
тап по узлам переключает сети.
|
тап по узлам переключает сети.
|
||||||
- Реальный путь (`/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` — ветка
|
||||||
|
остаётся раскрытой и после ухода курсора. Повторный тап снимает фиксацию.
|
||||||
|
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
|
||||||
|
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned` и `hovered` со всех.
|
||||||
|
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
|
||||||
|
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
|
||||||
|
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
|
||||||
|
дотягивается (`glideCameraTo` → `camTargetX/Y`, lerp `CAM_GLIDE_K` в tick). Любой жест отменяет доводчик.
|
||||||
|
- **Свободный зум:** колесо мыши (`onWheel`) и щипок двумя пальцами (`activePointers`/`pinching`) —
|
||||||
|
масштаб `zoom` (0.55–2.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-`scale`,
|
||||||
|
линии (отдельный SVG) пересчитываются в экранных координатах (× `zoom`).
|
||||||
|
- **Синхро-пульс линий:** сияющие/трековые «световоды» (`.fg-edge-glow`/`.fg-edge-core`) «дышат»
|
||||||
|
толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно).
|
||||||
|
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
|
||||||
|
|
||||||
|
> ⚠️ Эксперимент на ветке `pixel-web` (для отката). Реальный путь `/network-view` не затронут:
|
||||||
|
> весь deep-код под `tier ≥ 2` / `hasDeep`, hover-колбэк передаёт только лаборатория.
|
||||||
|
|
||||||
## Ограничения / на будущее
|
## Ограничения / на будущее
|
||||||
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
|
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
|
||||||
упираются в API (отдаёт только прямые связи) — требуют доработки сервера.
|
упираются в API (отдаёт только прямые связи) — требуют доработки сервера.
|
||||||
|
|||||||
@ -50,6 +50,18 @@ const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся
|
|||||||
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
|
const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px
|
||||||
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
|
const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px
|
||||||
|
|
||||||
|
// Зум камеры (свободное масштабирование колесом мыши / щипком двумя пальцами): мир целиком
|
||||||
|
// масштабируется CSS-scale (GPU), линии (отдельный SVG-слой) пересчитываются в экранных координатах.
|
||||||
|
const ZOOM_MIN = 0.55; // максимальное отдаление
|
||||||
|
const ZOOM_MAX = 2.6; // максимальное приближение
|
||||||
|
const ZOOM_WHEEL = 0.0016; // чувствительность колеса мыши
|
||||||
|
// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел
|
||||||
|
// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина).
|
||||||
|
const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла
|
||||||
|
// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков).
|
||||||
|
const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр)
|
||||||
|
const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px
|
||||||
|
|
||||||
const RELATION_COLORS = {
|
const RELATION_COLORS = {
|
||||||
family: 'rgba(255, 159, 94, 0.92)',
|
family: 'rgba(255, 159, 94, 0.92)',
|
||||||
friend: 'rgba(120, 179, 255, 0.9)',
|
friend: 'rgba(120, 179, 255, 0.9)',
|
||||||
@ -113,7 +125,7 @@ function hash01(str) {
|
|||||||
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
||||||
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
||||||
*/
|
*/
|
||||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) {
|
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover } = {}) {
|
||||||
// Слои DOM
|
// Слои DOM
|
||||||
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||||||
edgesSvg.setAttribute('class', 'fg-edges');
|
edgesSvg.setAttribute('class', 'fg-edges');
|
||||||
@ -126,9 +138,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
stage.append(edgesSvg, world, reticle);
|
stage.append(edgesSvg, world, reticle);
|
||||||
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
|
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
|
||||||
|
|
||||||
// Состояние камеры (панорамирование)
|
// Состояние камеры (панорамирование + зум)
|
||||||
let camX = 0;
|
let camX = 0;
|
||||||
let camY = 0;
|
let camY = 0;
|
||||||
|
let zoom = 1; // масштаб камеры (1 = базовый); меняется колесом мыши / щипком
|
||||||
|
let camTargetX = null; // цель дотяжки камеры-доводчика (null = доводчик выключен)
|
||||||
|
let camTargetY = null;
|
||||||
let viewW = stage.clientWidth || window.innerWidth;
|
let viewW = stage.clientWidth || window.innerWidth;
|
||||||
let viewH = stage.clientHeight || window.innerHeight;
|
let viewH = stage.clientHeight || window.innerHeight;
|
||||||
let centerX = viewW / 2;
|
let centerX = viewW / 2;
|
||||||
@ -231,7 +246,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус)
|
parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус)
|
||||||
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
|
||||||
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
|
||||||
expandTarget: 0, // 0/1 — раскрыты ли дочерние глубокие узлы (по клику/ховеру)
|
pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво»
|
||||||
|
hovered: false, // временно раскрыто наведением (мышь/палец) — пропадёт при уходе
|
||||||
expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс
|
expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс
|
||||||
dotOnly,
|
dotOnly,
|
||||||
strength,
|
strength,
|
||||||
@ -331,7 +347,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.parentId = String(src.parentId || '');
|
node.parentId = String(src.parentId || '');
|
||||||
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
|
node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2;
|
||||||
node.track = Boolean(src.track);
|
node.track = Boolean(src.track);
|
||||||
node.expandTarget = 0; node.expandP = 0; // при перестроении глубина схлопывается (раскрытие — по клику)
|
node.pinned = false; node.hovered = false; node.expandP = 0; // при перестроении глубина схлопывается
|
||||||
node.dotOnly = spec.dotOnly;
|
node.dotOnly = spec.dotOnly;
|
||||||
node.strength = strength;
|
node.strength = strength;
|
||||||
node.relationType = src.relationType;
|
node.relationType = src.relationType;
|
||||||
@ -355,7 +371,48 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
|
|
||||||
// --- Рендер ----------------------------------------------------------------
|
// --- Рендер ----------------------------------------------------------------
|
||||||
function applyWorldTransform() {
|
function applyWorldTransform() {
|
||||||
world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`;
|
world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся
|
||||||
|
// в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он
|
||||||
|
// упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет.
|
||||||
|
function glideCameraTo(n) {
|
||||||
|
if (!n) return;
|
||||||
|
const ring = (DEEP_R2 + 64) * zoom; // радиус раскрытого веера + запас (экранные px)
|
||||||
|
const sx = centerX + camX + n.x * zoom;
|
||||||
|
const sy = centerY + camY + n.y * zoom;
|
||||||
|
let tx = camX;
|
||||||
|
let ty = camY;
|
||||||
|
const m = CAM_GLIDE_MARGIN;
|
||||||
|
if (sx - ring < m) tx += (m - (sx - ring));
|
||||||
|
else if (sx + ring > viewW - m) tx -= ((sx + ring) - (viewW - m));
|
||||||
|
if (sy - ring < m) ty += (m - (sy - ring));
|
||||||
|
else if (sy + ring > viewH - m) ty -= ((sy + ring) - (viewH - m));
|
||||||
|
if (Math.abs(tx - camX) > 1 || Math.abs(ty - camY) > 1) { camTargetX = tx; camTargetY = ty; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Зум камеры (колесо мыши / щипок двумя пальцами) -----------------------
|
||||||
|
// Масштабируем «к точке» (sx,sy): мировая точка под курсором/центром щипка остаётся на месте.
|
||||||
|
function setZoom(nextZoom, sx, sy) {
|
||||||
|
const z0 = zoom;
|
||||||
|
const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
|
||||||
|
if (Math.abs(z1 - z0) < 0.0001) return;
|
||||||
|
const wx = (sx - centerX - camX) / z0;
|
||||||
|
const wy = (sy - centerY - camY) / z0;
|
||||||
|
zoom = z1;
|
||||||
|
camX = sx - centerX - wx * z1;
|
||||||
|
camY = sy - centerY - wy * z1;
|
||||||
|
camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик
|
||||||
|
applyWorldTransform();
|
||||||
|
renderEdges();
|
||||||
|
updateReticle();
|
||||||
|
}
|
||||||
|
function onWheel(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const rect = stage.getBoundingClientRect();
|
||||||
|
setZoom(zoom * Math.exp(-ev.deltaY * ZOOM_WHEEL), ev.clientX - rect.left, ev.clientY - rect.top);
|
||||||
|
wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNodes() {
|
function renderNodes() {
|
||||||
@ -393,10 +450,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет.
|
// Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет.
|
||||||
|
// Эффективная цель раскрытия узла: раскрыт, если зафиксирован кликом (pinned) ИЛИ временно наведён (hovered).
|
||||||
|
function expandTargetOf(n) { return (n.pinned || n.hovered) ? 1 : 0; }
|
||||||
|
|
||||||
function advanceExpand() {
|
function advanceExpand() {
|
||||||
let moving = false;
|
let moving = false;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const t = n.expandTarget || 0;
|
const t = expandTargetOf(n);
|
||||||
const cur = n.expandP || 0;
|
const cur = n.expandP || 0;
|
||||||
if (Math.abs(cur - t) > 0.001) {
|
if (Math.abs(cur - t) > 0.001) {
|
||||||
let next = cur + (t - cur) * 0.18;
|
let next = cur + (t - cur) * 0.18;
|
||||||
@ -424,9 +484,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
edgesSvg.innerHTML = '';
|
edgesSvg.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость
|
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
|
||||||
const tx = (n) => centerX + camX + n.x;
|
// Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
|
||||||
const ty = (n) => centerY + camY + n.y;
|
const Z = zoom;
|
||||||
|
const tx = (n) => centerX + camX + n.x * Z;
|
||||||
|
const ty = (n) => centerY + camY + n.y * Z;
|
||||||
|
|
||||||
const focusLogin = String(focus.login || '').toLowerCase();
|
const focusLogin = String(focus.login || '').toLowerCase();
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@ -440,9 +502,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue;
|
||||||
// начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель
|
// начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель
|
||||||
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
|
const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
|
||||||
const fx = centerX + camX + parent.x;
|
const fx = centerX + camX + parent.x * Z;
|
||||||
const fy = centerY + camY + parent.y;
|
const fy = centerY + camY + parent.y * Z;
|
||||||
const fr = parent.dotRadius * parent.scale + 4;
|
const fr = parent.dotRadius * parent.scale * Z + 4;
|
||||||
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;
|
||||||
@ -452,14 +514,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
|
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
|
||||||
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
|
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
|
||||||
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
|
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
|
||||||
const ex = growing ? (centerX + camX + n.bfx) : nx;
|
const ex = growing ? (centerX + camX + n.bfx * Z) : nx;
|
||||||
const ey = growing ? (centerY + camY + n.bfy) : ny;
|
const ey = growing ? (centerY + camY + n.bfy * Z) : ny;
|
||||||
const dx = ex - fx;
|
const dx = ex - fx;
|
||||||
const dy = ey - fy;
|
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 * Z + 4;
|
||||||
// концы линии — у краёв кружков
|
// концы линии — у краёв кружков
|
||||||
const x1 = fx + ux * fr;
|
const x1 = fx + ux * fr;
|
||||||
const y1 = fy + uy * fr;
|
const y1 = fy + uy * fr;
|
||||||
@ -540,7 +602,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let best = Infinity;
|
let best = Infinity;
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.hidden) continue;
|
if (n.hidden) continue;
|
||||||
const d = Math.hypot(camX + n.x, camY + n.y);
|
const d = Math.hypot(camX + n.x * zoom, camY + n.y * zoom);
|
||||||
if (d < best) best = d;
|
if (d < best) best = d;
|
||||||
}
|
}
|
||||||
reticle.classList.toggle('is-locked', best < 46);
|
reticle.classList.toggle('is-locked', best < 46);
|
||||||
@ -585,7 +647,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let dist2 = dx * dx + dy * dy;
|
let dist2 = dx * dx + dy * dy;
|
||||||
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST;
|
||||||
const dist = Math.sqrt(dist2);
|
const dist = Math.sqrt(dist2);
|
||||||
const f = chargeNow / dist2;
|
// адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание
|
||||||
|
// пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты.
|
||||||
|
const ex = Math.max(n.expandP || 0, m.expandP || 0);
|
||||||
|
const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2;
|
||||||
ax += (dx / dist) * f;
|
ax += (dx / dist) * f;
|
||||||
ay += (dy / dist) * f;
|
ay += (dy / dist) * f;
|
||||||
}
|
}
|
||||||
@ -822,6 +887,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); }
|
if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); }
|
||||||
else if (pbMag < 12) panTwang = false;
|
else if (pbMag < 12) panTwang = false;
|
||||||
|
|
||||||
|
// камера-доводчик: плавно дотягиваем камеру к цели (после раскрытия ветки). Жест уже обнулил цель.
|
||||||
|
let camGliding = false;
|
||||||
|
if (camTargetX !== null && !dragging && !panActive) {
|
||||||
|
camX += (camTargetX - camX) * CAM_GLIDE_K;
|
||||||
|
camY += (camTargetY - camY) * CAM_GLIDE_K;
|
||||||
|
if (Math.abs(camTargetX - camX) < 0.5 && Math.abs(camTargetY - camY) < 0.5) {
|
||||||
|
camX = camTargetX; camY = camTargetY; camTargetX = null; camTargetY = null;
|
||||||
|
} else { camGliding = true; }
|
||||||
|
applyWorldTransform();
|
||||||
|
}
|
||||||
|
|
||||||
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
// динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80),
|
||||||
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
// отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле»
|
||||||
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
|
frictionNow = FRICTION + boost * (FRICTION_BOOST - FRICTION);
|
||||||
@ -841,7 +917,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
renderAll();
|
renderAll();
|
||||||
|
|
||||||
const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
|
const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад
|
||||||
if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
|
if (tween || dragging || panActive || camGliding || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) {
|
||||||
schedule();
|
schedule();
|
||||||
} else {
|
} else {
|
||||||
freezeGraph(); // система успокоилась — замираем
|
freezeGraph(); // система успокоилась — замираем
|
||||||
@ -866,22 +942,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let downNodeEl = null;
|
let downNodeEl = null;
|
||||||
let longTimer = 0;
|
let longTimer = 0;
|
||||||
let longFired = false;
|
let longFired = false;
|
||||||
|
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
|
||||||
|
let pinching = false; // активен щипок (pan/tap на это время заморожены)
|
||||||
|
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
|
||||||
|
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
|
||||||
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
|
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует.
|
||||||
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
|
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } };
|
||||||
|
|
||||||
// Режим «Интерактивная паутина»: тап по узлу РАСКРЫВАЕТ/СВОРАЧИВАЕТ его ветку НА МЕСТЕ (без смены
|
// Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см.
|
||||||
// центра). expandTarget 0↔1, плавная анимация expandP (см. advanceExpand/layoutDeep).
|
// advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение).
|
||||||
|
// Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся
|
||||||
|
// раскрытой, даже когда убрали палец/мышь (в отличие от ховера). + камера-доводчик подводит кластер в кадр.
|
||||||
function toggleExpand(node) {
|
function toggleExpand(node) {
|
||||||
const n = node && (nodeById.get(String(node.id)) || node);
|
const n = node && (nodeById.get(String(node.id)) || node);
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
n.expandTarget = n.expandTarget ? 0 : 1;
|
n.pinned = !n.pinned;
|
||||||
if (n.expandTarget) haptic([10, 25, 6, 35, 3]); // «выброс вселенной» — серия затухающих импульсов
|
if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры
|
||||||
wake();
|
wake();
|
||||||
}
|
}
|
||||||
// Глобальный сброс: рекурсивно сворачиваем ВСЕ раскрытые ветки (тап по корню — Ивану).
|
// Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец
|
||||||
|
// над узлом, и втягивается при уходе (если узел не закреплён кликом). node=null — снять ховер со всех.
|
||||||
|
function setHover(node) {
|
||||||
|
const target = node && (nodeById.get(String(node.id)) || node);
|
||||||
|
let changed = false;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const want = n === target;
|
||||||
|
if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) wake();
|
||||||
|
}
|
||||||
|
// Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток (тап по корню — Ивану).
|
||||||
function collapseAll() {
|
function collapseAll() {
|
||||||
let any = false;
|
let any = false;
|
||||||
for (const n of nodes) { if (n.expandTarget) { n.expandTarget = 0; any = true; } }
|
for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } }
|
||||||
if (any) haptic(14);
|
if (any) haptic(14);
|
||||||
wake();
|
wake();
|
||||||
}
|
}
|
||||||
@ -894,10 +987,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(ev) {
|
function onPointerDown(ev) {
|
||||||
|
activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||||
|
// второй палец → режим щипкового зума: прерываем pan/tap/longpress, фиксируем базовую дистанцию
|
||||||
|
if (activePointers.size === 2) {
|
||||||
|
pinching = true;
|
||||||
|
camTargetX = null; camTargetY = null;
|
||||||
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
|
if (downNodeEl) { downNodeEl.classList.remove('is-pressed'); }
|
||||||
|
dragging = false;
|
||||||
|
const pts = [...activePointers.values()];
|
||||||
|
pinchDist0 = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pointerId !== null) return;
|
if (pointerId !== null) return;
|
||||||
pointerId = ev.pointerId;
|
pointerId = ev.pointerId;
|
||||||
panVelX = 0; // новое касание мгновенно прерывает инерцию
|
panVelX = 0; // новое касание мгновенно прерывает инерцию
|
||||||
panVelY = 0;
|
panVelY = 0;
|
||||||
|
camTargetX = null; camTargetY = null; // касание отменяет доводчик камеры
|
||||||
try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ }
|
try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ }
|
||||||
downX = ev.clientX;
|
downX = ev.clientX;
|
||||||
downY = ev.clientY;
|
downY = ev.clientY;
|
||||||
@ -908,6 +1014,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||||
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
||||||
const downNode = nodeFromEvent(ev);
|
const downNode = nodeFromEvent(ev);
|
||||||
|
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
|
||||||
|
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
|
||||||
if (downNode && typeof onNodeLongPress === 'function') {
|
if (downNode && typeof onNodeLongPress === 'function') {
|
||||||
longTimer = window.setTimeout(() => {
|
longTimer = window.setTimeout(() => {
|
||||||
if (moved) return;
|
if (moved) return;
|
||||||
@ -919,7 +1027,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ховер мышью: наведение на узел → превью ветки; уход с узла → сворачивание (если не закреплён кликом).
|
||||||
|
function onPointerOver(ev) {
|
||||||
|
if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
|
||||||
|
const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
|
||||||
|
const node = el ? nodes.find((n) => String(n.id) === String(el.dataset.nodeId)) : null;
|
||||||
|
if (node && node !== hoverNode) { hoverNode = node; onNodeHover(node, true); }
|
||||||
|
}
|
||||||
|
function onPointerOut(ev) {
|
||||||
|
if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return;
|
||||||
|
const toEl = ev.relatedTarget instanceof Element ? ev.relatedTarget.closest('.fg-node') : null;
|
||||||
|
if (toEl && hoverNode && String(toEl.dataset.nodeId) === String(hoverNode.id)) return; // ещё внутри узла
|
||||||
|
if (hoverNode) { hoverNode = null; onNodeHover(null, false); }
|
||||||
|
}
|
||||||
|
|
||||||
function onPointerMove(ev) {
|
function onPointerMove(ev) {
|
||||||
|
if (activePointers.has(ev.pointerId)) activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||||
|
// щипок двумя пальцами: масштабируем относительно центра между пальцами (зум «к точке»)
|
||||||
|
if (pinching && activePointers.size >= 2) {
|
||||||
|
const pts = [...activePointers.values()];
|
||||||
|
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
|
||||||
|
const rect = stage.getBoundingClientRect();
|
||||||
|
const mx = (pts[0].x + pts[1].x) / 2 - rect.left;
|
||||||
|
const my = (pts[0].y + pts[1].y) / 2 - rect.top;
|
||||||
|
setZoom(zoom * (dist / pinchDist0), mx, my);
|
||||||
|
pinchDist0 = dist;
|
||||||
|
wake();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ev.pointerId !== pointerId) return;
|
if (ev.pointerId !== pointerId) return;
|
||||||
const dx = ev.clientX - downX;
|
const dx = ev.clientX - downX;
|
||||||
const dy = ev.clientY - downY;
|
const dy = ev.clientY - downY;
|
||||||
@ -927,6 +1062,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
moved = true;
|
moved = true;
|
||||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
|
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
|
||||||
|
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
|
||||||
|
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||||
|
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
|
||||||
cancelTween(); // жест прерывает анимацию центрирования
|
cancelTween(); // жест прерывает анимацию центрирования
|
||||||
dragging = true;
|
dragging = true;
|
||||||
}
|
}
|
||||||
@ -945,6 +1083,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(ev) {
|
function onPointerUp(ev) {
|
||||||
|
activePointers.delete(ev.pointerId);
|
||||||
|
// выход из щипка: пока пальцев <2 — щипок завершён; остаток НЕ превращаем в pan/tap (избегаем рывка)
|
||||||
|
if (pinching) {
|
||||||
|
if (activePointers.size < 2) { pinching = false; pinchDist0 = 0; }
|
||||||
|
if (ev.pointerId === pointerId) { pointerId = null; dragging = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ev.pointerId !== pointerId) return;
|
if (ev.pointerId !== pointerId) return;
|
||||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
|
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается
|
||||||
@ -953,6 +1098,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const wasLong = longFired;
|
const wasLong = longFired;
|
||||||
pointerId = null;
|
pointerId = null;
|
||||||
dragging = false;
|
dragging = false;
|
||||||
|
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
|
||||||
|
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||||
|
|
||||||
if (wasMoved || wasLong) {
|
if (wasMoved || wasLong) {
|
||||||
// после pan даём физике чуть устаканиться и уснуть
|
// после pan даём физике чуть устаканиться и уснуть
|
||||||
@ -1020,8 +1167,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const dot = n.el.querySelector('.node-dot') || n.el;
|
const dot = n.el.querySelector('.node-dot') || n.el;
|
||||||
const r = dot.getBoundingClientRect();
|
const r = dot.getBoundingClientRect();
|
||||||
n.x = (r.left + r.width / 2) - sr.left - centerX - camX;
|
n.x = ((r.left + r.width / 2) - sr.left - centerX - camX) / zoom;
|
||||||
n.y = (r.top + r.height / 2) - sr.top - centerY - camY;
|
n.y = ((r.top + r.height / 2) - sr.top - centerY - camY) / zoom;
|
||||||
// живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой
|
// живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой
|
||||||
const o = parseFloat(getComputedStyle(n.el).opacity);
|
const o = parseFloat(getComputedStyle(n.el).opacity);
|
||||||
if (Number.isFinite(o)) n.opacity = o;
|
if (Number.isFinite(o)) n.opacity = o;
|
||||||
@ -1158,6 +1305,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
stage.addEventListener('pointermove', onPointerMove);
|
stage.addEventListener('pointermove', onPointerMove);
|
||||||
stage.addEventListener('pointerup', onPointerUp);
|
stage.addEventListener('pointerup', onPointerUp);
|
||||||
stage.addEventListener('pointercancel', onPointerUp);
|
stage.addEventListener('pointercancel', onPointerUp);
|
||||||
|
stage.addEventListener('pointerover', onPointerOver); // ховер мышью → превью ветки
|
||||||
|
stage.addEventListener('pointerout', onPointerOut);
|
||||||
|
stage.addEventListener('wheel', onWheel, { passive: false }); // зум колесом мыши
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
let ro = null;
|
let ro = null;
|
||||||
@ -1172,7 +1322,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
recenter: (id) => startRecenterTween(id),
|
recenter: (id) => startRecenterTween(id),
|
||||||
setModel,
|
setModel,
|
||||||
setFilter,
|
setFilter,
|
||||||
toggleExpand, // mind-map: раскрыть/свернуть ветку узла на месте
|
toggleExpand, // mind-map: ЗАФИКСИРОВАТЬ/снять раскрытие ветки кликом (pinned)
|
||||||
|
setHover, // mind-map: ВРЕМЕННОЕ раскрытие ветки наведением (node | null)
|
||||||
collapseAll, // mind-map: свернуть все ветки (тап по корню)
|
collapseAll, // mind-map: свернуть все ветки (тап по корню)
|
||||||
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
|
getFocusNode: () => nodes.find((n) => n.isFocus) || null,
|
||||||
destroy() {
|
destroy() {
|
||||||
@ -1183,6 +1334,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
stage.removeEventListener('pointermove', onPointerMove);
|
stage.removeEventListener('pointermove', onPointerMove);
|
||||||
stage.removeEventListener('pointerup', onPointerUp);
|
stage.removeEventListener('pointerup', onPointerUp);
|
||||||
stage.removeEventListener('pointercancel', onPointerUp);
|
stage.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
stage.removeEventListener('pointerover', onPointerOver);
|
||||||
|
stage.removeEventListener('pointerout', onPointerOut);
|
||||||
|
stage.removeEventListener('wheel', onWheel);
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
if (ro) ro.disconnect();
|
if (ro) ro.disconnect();
|
||||||
edgesSvg.remove();
|
edgesSvg.remove();
|
||||||
|
|||||||
@ -46,9 +46,10 @@ function helpText() {
|
|||||||
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
||||||
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
||||||
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
||||||
'• Чип «Вселенная» — прототип глубины 2-3 уровней (фейковые связи, для наглядности).',
|
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
|
||||||
' По умолчанию дальние связи скрыты. Зажми/наведи узел — его микро-связи выплывают',
|
' По умолчанию дальние связи скрыты. Наведи мышь/палец на узел — его микро-связи временно',
|
||||||
' вокруг, отпусти — втягиваются обратно. Перейдёшь на узел — путь до него горит треком.',
|
' выплывают (превью), убери — втягиваются. Кликни/тапни узел — раскрытие ФИКСИРУЕТСЯ.',
|
||||||
|
' Тап по центру (Ивану) — свернуть все ветки. Колесо мыши / щипок — зум. Свайп — pan.',
|
||||||
'',
|
'',
|
||||||
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@ -189,7 +190,8 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
||||||
onNodeTap: (node) => {
|
onNodeTap: (node) => {
|
||||||
if (deepMode) {
|
if (deepMode) {
|
||||||
// режим «Интерактивная паутина»: НЕ меняем центр — раскрываем/сворачиваем ветку узла на месте
|
// режим «Интерактивная паутина»: НЕ меняем центр — клик ФИКСИРУЕТ раскрытие ветки (остаётся
|
||||||
|
// открытой и после ухода курсора); повторный клик снимает фиксацию.
|
||||||
graph.toggleExpand(node);
|
graph.toggleExpand(node);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -199,6 +201,9 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
},
|
},
|
||||||
|
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
|
||||||
|
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
|
||||||
|
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
|
||||||
onCenterTap: (node) => {
|
onCenterTap: (node) => {
|
||||||
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
||||||
if (deepMode) { graph.collapseAll(); return; }
|
if (deepMode) { graph.collapseAll(); return; }
|
||||||
|
|||||||
@ -68,12 +68,30 @@
|
|||||||
stroke-width: 4;
|
stroke-width: 4;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
|
filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
|
||||||
|
/* синхро-пульс: нить «дышит» толщиной/размытием в том же ритме (3.6с), что и ободок сияющего узла —
|
||||||
|
в покое SVG не перерисовывается, поэтому все нити стартуют синхронно и пульсируют вместе. */
|
||||||
|
animation: fg-edge-pulse 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.fg-edge-core {
|
.fg-edge-core {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
|
stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
|
animation: fg-edge-core-pulse 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* пульс «световода» в такт дыханию сияющего ободка (та же длительность 3.6с) */
|
||||||
|
@keyframes fg-edge-pulse {
|
||||||
|
0%, 100% { stroke-width: 3.4; filter: blur(1.6px); }
|
||||||
|
50% { stroke-width: 5.2; filter: blur(2.8px); }
|
||||||
|
}
|
||||||
|
@keyframes fg-edge-core-pulse {
|
||||||
|
0%, 100% { stroke-width: 1.3; }
|
||||||
|
50% { stroke-width: 1.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fg-edge-glow, .fg-edge-core { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node {
|
.fg-node {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user