From f92e6c3cf1b17b2d2781bc2cdfd1523e7a8daffb6199a3d678989849aec425d5 Mon Sep 17 00:00:00 2001 From: Pixel Date: Tue, 9 Jun 2026 22:55:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=B8=20(pixel-web):?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=83=D1=82=D0=B8=D0=BD=D1=8B=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D0=B9=202-=D0=B9=20=D1=83=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C,=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=20=C2=AB=D0=BF=D1=80=D0=B8=D1=86=D0=B5=D0=BB=C2=BB?= =?UTF-8?q?,=20toggle+spotlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправлены замечания по видео (режим «Вселенная», только лаборатория): 1. «Невидимые друзья» (узлы 2-го уровня). Раньше — тусклые пустые кружки с инициалом. Теперь tier-2 — полноценные аватарки: фото-лицо (pravatar) + имя, DEEP2_SCALE 0.5→0.62 (≈radius 16px), DEEP2_OPACITY 0.4→0.85; читаемый ободок и подпись (CSS). 2. Мусорный «прицел» (пунктирное кольцо у центра). Полностью удалён из движка: элемент .fg-reticle, updateReticle/pulseReticle и все их вызовы, CSS .fg-reticle*. На экране только аватарки и линии связей. 3. Логика toggle + spotlight: - Повторный клик по раскрытому узлу теперь СВОРАЧИВАЕТ его (isOpen = pinned || expandP>0.5 → сброс pinned+hovered) — работает, даже если ветка была раскрыта ховером. - Spotlight: при закреплённой ветке остальные тускнеют до 0.25 (узлы и линии), фокус и закреплённая/наведённая ветка — 100%; плавно через spotCur (lerp, см. spotTargetOf). - Клик по центру (Иван) — collapseAll + возврат всему графу 100% яркости. Реальный путь /network-view не затронут (deep-код под tier≥2/hasDeep). Ветка экспериментальная. Бамп client.version → 1.2.145. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION.properties | 4 +- .../features/interactive-network-graph.md | 11 ++- shine-UI/js/pages/network/force-graph.js | 89 ++++++++++--------- shine-UI/js/pages/network/lab.js | 4 +- shine-UI/styles/network-graph.css | 45 ++-------- 5 files changed, 63 insertions(+), 90 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index be04c84..5b9a6ee 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.144 -server.version=1.2.128 +client.version=1.2.145 +server.version=1.2.129 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 419ca65..5ef348d 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -61,7 +61,7 @@ (`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными. - **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`, `backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым. -- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация +- **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной `box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`); хард-лимит ~90 DOM-аватарок (остальное — SVG-точки). @@ -94,9 +94,14 @@ втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover` → `graph.setHover(node|null)`; узел получает флаг `hovered`. - **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка - остаётся раскрытой и после ухода курсора. Повторный тап снимает фиксацию. + остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его + (надёжный toggle: `isOpen = pinned || expandP>0.5` → сброс `pinned`+`hovered`). - Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс). -- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned` и `hovered` со всех. +- **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25` + (узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp). +- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62` + (≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей. +- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость. - **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов 1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь. - **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index eca2f91..a66f74d 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -44,8 +44,8 @@ const MAX_FULL_NODES = 90; // хард-лимит полных DOM-ават // «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не // участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep). -const DEEP2_SCALE = 0.5; // узел 2-го уровня — вдвое меньше -const DEEP2_OPACITY = 0.4; // и полупрозрачный +const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем +const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка» const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка) const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px @@ -58,6 +58,7 @@ const ZOOM_WHEEL = 0.0016; // чувствительность колеса // Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел // сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина). const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла +const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом // Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков). const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр) const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px @@ -131,11 +132,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL edgesSvg.setAttribute('class', 'fg-edges'); const world = document.createElement('div'); world.className = 'fg-world'; - // «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется, - // когда под него попадает узел (визуальная зона фокуса при свободном панорамировании). - const reticle = document.createElement('div'); - reticle.className = 'fg-reticle'; - stage.append(edgesSvg, world, reticle); + stage.append(edgesSvg, world); ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) // Состояние камеры (панорамирование + зум) @@ -154,8 +151,24 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let focusId = ''; let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней) let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки) + let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют) const rebuildIndex = () => { nodeById = new Map(nodes.map((n) => [String(n.id), n])); hasDeep = nodes.some((n) => n.tier >= 2); }; + // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы + // взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка + // 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности. + function rootTier1(n) { + let r = n; let guard = 0; + while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null; + return r; + } + function spotTargetOf(n) { + if (!spotActive || n.isFocus) return 1; + const r = rootTier1(n); + if (!r) return SPOTLIGHT_DIM; + return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM; + } + // Управление циклом rAF let rafId = 0; let dragging = false; @@ -264,6 +277,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL hidden: false, opacity: tier >= 2 ? op : 1, targetOpacity: op, + spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет) bloom: false, edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) el, @@ -347,7 +361,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.parentId = String(src.parentId || ''); node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2; node.track = Boolean(src.track); - node.pinned = false; node.hovered = false; node.expandP = 0; // при перестроении глубина схлопывается + node.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается node.dotOnly = spec.dotOnly; node.strength = strength; node.relationType = src.relationType; @@ -406,7 +420,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик applyWorldTransform(); renderEdges(); - updateReticle(); } function onWheel(ev) { ev.preventDefault(); @@ -419,7 +432,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const n of nodes) { n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`; - n.el.style.opacity = String(n.opacity); + n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : ''; } } @@ -474,7 +487,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const n of nodes) { if (n.tier < 2) continue; n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`; - n.el.style.opacity = String(n.opacity); + n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight: затемнённые ветки тусклее } } @@ -554,6 +567,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). const shine = Boolean(n.shining) && !n.hidden; + const sp = (n.spotCur ?? 1); // spotlight: линия тускнеет вместе со своим узлом (затемнённые ветки) const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла // (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра. @@ -568,15 +582,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) if (n.tier >= 3) { // 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии - if (pe > 0.02) parts.push(``); + if (pe > 0.02) parts.push(``); } else if (n.tier === 2) { // 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) - if (pe > 0.02) parts.push(``); + if (pe > 0.02) parts.push(``); } else if (shine || n.track) { // glow (размытый, приглушённый) + core (тонкий, чёткий). Используется и для «трека прохождения» // (n.track) — линия к предыдущему фокусу горит так же ярко, показывая цепочку навигации. - const gOp = (0.4 * nodeOpacity).toFixed(2); - const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; + const cOpVal = nodeOpacity * sp; + const gOp = (0.4 * cOpVal).toFixed(2); + const cOpAttr = cOpVal < 0.995 ? ` opacity="${cOpVal.toFixed(2)}"` : ''; parts.push(``); parts.push(``); } else { @@ -590,28 +605,17 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL + `` ); const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.0–1.2px - const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : ''; + const opVal = nodeOpacity * sp; + const op = opVal < 0.995 ? ` opacity="${opVal.toFixed(2)}"` : ''; parts.push(``); } } edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`; } - function updateReticle() { - // ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0) - let best = Infinity; - for (const n of nodes) { - if (n.hidden) continue; - const d = Math.hypot(camX + n.x * zoom, camY + n.y * zoom); - if (d < best) best = d; - } - reticle.classList.toggle('is-locked', best < 46); - } - function renderAll() { renderNodes(); renderEdges(); - updateReticle(); } // --- Физика (пружины + отталкивание) --------------------------------------- @@ -665,11 +669,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return totalV; } - // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»). + // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание») + spotlight. function advanceVisual() { + spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют for (const n of nodes) { n.scale += (n.targetScale - n.scale) * 0.2; n.opacity += (n.targetOpacity - n.opacity) * 0.2; + n.spotCur += (spotTargetOf(n) - n.spotCur) * 0.2; // плавное затемнение/прояснение веток // линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08); } @@ -678,7 +684,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки). function visualSettling() { for (const n of nodes) { - if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true; + if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 + || Math.abs(n.spotCur - spotTargetOf(n)) > 0.01 || n.edgeGrow < 1) return true; } return false; } @@ -859,7 +866,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL layoutDeep(); renderDeepNodes(); renderEdges(); - updateReticle(); schedule(); return; } @@ -956,8 +962,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function toggleExpand(node) { const n = node && (nodeById.get(String(node.id)) || node); if (!n) return; - n.pinned = !n.pinned; - if (n.pinned) { haptic([10, 25, 6, 35, 3]); glideCameraTo(n); } // «выброс вселенной» + дотяжка камеры + // надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером). + const isOpen = n.pinned || (n.expandP || 0) > 0.5; + if (isOpen) { + n.pinned = false; n.hovered = false; // свернуть полностью + haptic(8); + } else { + n.pinned = true; haptic([10, 25, 6, 35, 3]); glideCameraTo(n); // раскрыть + дотяжка камеры + } wake(); } // Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец @@ -1078,7 +1090,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL applyWorldTransform(); advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа renderEdges(); // рёбра следуют за камерой синхронно (дёшево) - updateReticle(); } } @@ -1152,14 +1163,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс } - // Импульс центрального кольца — подтверждение «захвата» нового фокуса. - function pulseReticle() { - reticle.classList.remove('is-pulse'); - void reticle.offsetWidth; - reticle.classList.add('is-pulse'); - window.setTimeout(() => reticle.classList.remove('is-pulse'), 620); - } - // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM, // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками. function syncPositionsFromDOM() { @@ -1296,7 +1299,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost) renderEdges(); - pulseReticle(); cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано wake(); } @@ -1341,7 +1343,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (ro) ro.disconnect(); edgesSvg.remove(); world.remove(); - reticle.remove(); }, }; } diff --git a/shine-UI/js/pages/network/lab.js b/shine-UI/js/pages/network/lab.js index 2f7b0ba..57f18d8 100644 --- a/shine-UI/js/pages/network/lab.js +++ b/shine-UI/js/pages/network/lab.js @@ -93,9 +93,11 @@ function addDeepLevels(model) { for (let i = 0; i < k2; i += 1) { const id2 = `${p.id}__d2_${i}`; const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6; + // узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя + const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`; extra.push({ id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)], - avatar: null, photo: null, relationType: p.relationType || 'contact', + avatar: null, photo: face2, relationType: p.relationType || 'contact', strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, }); const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5 diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 82fff1c..5ce08d6 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -292,15 +292,15 @@ .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } /* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */ -/* 2-й уровень — «друзья друзей»: масштаб/прозрачность задаёт движок; тут убираем жирные тени - и делаем подпись мельче/тусклее, чтобы дальние узлы не спорили с основными. */ +/* 2-й уровень — «друзья друзей»: полноценная аватарка (лицо + имя), просто мельче основных. + Масштаб/прозрачность задаёт движок; здесь — читаемый ободок и подпись (не «дырка»). */ .fg-node.is-tier2 .node-dot { - border-color: rgba(150, 180, 220, 0.4); - box-shadow: none; + border-color: rgba(170, 200, 240, 0.65); + box-shadow: 0 2px 8px rgba(4, 8, 15, 0.4); } .fg-node.is-tier2 .fg-node-label { font-size: 9px; - opacity: 0.55; + opacity: 0.9; top: calc(100% + 1px); } @@ -337,30 +337,6 @@ color: #efeaff; } -/* «Прицел» в центре экрана (зона фокуса) — позади узлов */ -.fg-reticle { - position: absolute; - left: 50%; - top: 50%; - width: 64px; - height: 64px; - margin: -32px 0 0 -32px; - border-radius: 50%; - border: 2px dashed rgba(150, 190, 255, 0.3); - pointer-events: none; - z-index: 0; - opacity: 0.45; - transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease; -} - -.fg-reticle.is-locked { - width: 94px; - height: 94px; - margin: -47px 0 0 -47px; - border-color: rgba(130, 235, 255, 0.65); - opacity: 0.85; -} - /* «Призрак» старой карты при Z-переходе (эффект погружения) */ .fg-ghost-layer { position: absolute; @@ -373,17 +349,6 @@ transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease; } -/* Импульс центрального кольца при захвате нового фокуса */ -.fg-reticle.is-pulse { - animation: fg-reticle-pulse 0.6s ease; -} - -@keyframes fg-reticle-pulse { - 0% { transform: scale(1); } - 40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); } - 100% { transform: scale(1); } -} - /* Панель фильтров слоёв (оверлей под шапкой) */ .fg-filter-bar { position: absolute;