Связи: финальный вид сияющих связей
Перенести финальный плазменный рендер связей из коммита Pixel 2559f1e6 в main.
This commit is contained in:
parent
9ca469a075
commit
e3061b46f9
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.150
|
client.version=1.2.151
|
||||||
server.version=1.2.142
|
server.version=1.2.143
|
||||||
|
|||||||
@ -45,10 +45,12 @@
|
|||||||
- **Обычные линии:** SVG `<path> Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.0–1.2`),
|
- **Обычные линии:** SVG `<path> Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.0–1.2`),
|
||||||
градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются
|
градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются
|
||||||
в фоне, не спорят с сияющими). Изгиб реагирует на скорость.
|
в фоне, не спорят с сияющими). Изгиб реагирует на скорость.
|
||||||
- **Сияющие связи (двухслойный «световод», Neon Layering):** два пути на одну связь — широкий размытый
|
- **Сияющие связи (плазменный композитинг):** один центральный S-путь (cubic Bézier) и три наложенных
|
||||||
GLOW (`stroke-width 4`, неон, `filter: blur(2px)`, `opacity 0.4`) + тонкий чёткий CORE (`1.5px`,
|
слоя с одинаковым `d`: `.fg-plasma-flare` (16px, `#00bfff`, blur6), `.fg-plasma-tube` (6px, `#00e5ff`,
|
||||||
`#e0f7fc`). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий
|
blur2) и `.fg-plasma-core` (2px, `#dffaff`). Поле и трубка идут в `mix-blend-mode: screen`, поэтому
|
||||||
dashoffset). Никаких бегущих импульсов.
|
свечение складывается аддитивно с тёмным фоном и ярче проявляется в пересечениях у центра.
|
||||||
|
- **Обычные связи:** теперь это мягкое цветное свечение по типу связи (семья/друзья/бизнес/контакт) —
|
||||||
|
широкая полупрозрачная подложка плюс тонкое ядро, без SVG-blur.
|
||||||
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
||||||
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
||||||
по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не
|
по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не
|
||||||
|
|||||||
@ -71,8 +71,11 @@ function ensureShineFilter() {
|
|||||||
svg.setAttribute('width', '0');
|
svg.setAttribute('width', '0');
|
||||||
svg.setAttribute('height', '0');
|
svg.setAttribute('height', '0');
|
||||||
svg.style.position = 'absolute';
|
svg.style.position = 'absolute';
|
||||||
svg.innerHTML = '<defs><filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" '
|
svg.innerHTML = '<defs>'
|
||||||
+ 'color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></filter></defs>';
|
+ '<filter id="fg-shine-glow" x="-120%" y="-120%" width="340%" height="340%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.4"/></filter>'
|
||||||
|
+ '<filter id="fg-plasma-blur6" x="-100%" y="-200%" width="300%" height="500%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6"/></filter>'
|
||||||
|
+ '<filter id="fg-plasma-blur2" x="-60%" y="-120%" width="220%" height="340%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="2"/></filter>'
|
||||||
|
+ '</defs>';
|
||||||
document.body.appendChild(svg);
|
document.body.appendChild(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,9 +391,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
|
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
|
||||||
const cpy = 2 * desY - my;
|
const cpy = 2 * desY - my;
|
||||||
// Связь рисуем по статусу узла:
|
// Связь рисуем по статусу узла:
|
||||||
// • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
|
// • обычная — мягкое цветное свечение по типу связи;
|
||||||
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
|
// • сияющая — плазма из трёх слоёв на одном S-пути (flare/tube/core).
|
||||||
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
|
|
||||||
const shine = Boolean(n.shining) && !n.hidden;
|
const shine = Boolean(n.shining) && !n.hidden;
|
||||||
const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
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 по мере разлёта узла
|
// ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
|
||||||
@ -404,24 +406,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
|
||||||
}
|
}
|
||||||
if (shine) {
|
if (shine) {
|
||||||
// glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr)
|
const pnx = -uy;
|
||||||
const gOp = (0.4 * nodeOpacity).toFixed(2);
|
const pny = ux;
|
||||||
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
|
const amp = Math.min(13, 5 + segLen0 * 0.05);
|
||||||
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
|
const bowX = desX - mx;
|
||||||
parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`);
|
const bowY = desY - my;
|
||||||
|
const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp;
|
||||||
|
const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp;
|
||||||
|
const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp;
|
||||||
|
const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp;
|
||||||
|
const dS = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
||||||
|
parts.push(`<path class="fg-plasma-flare" d="${dS}"${dashAttr} opacity="${(0.42 * nodeOpacity).toFixed(3)}" />`);
|
||||||
|
parts.push(`<path class="fg-plasma-tube" d="${dS}"${dashAttr} opacity="${(0.85 * nodeOpacity).toFixed(3)}" />`);
|
||||||
|
parts.push(`<path class="fg-plasma-core" d="${dS}"${dashAttr} opacity="${nodeOpacity.toFixed(3)}" />`);
|
||||||
} else {
|
} else {
|
||||||
// обычная: тонкая дуга, градиент 0.42 (центр) → 0.07 (аватарка) — глубокий уход в прозрачность,
|
const col = relationColor(n.relationType);
|
||||||
// чтобы матовые связи не спорили с сияющими.
|
const haloOp = (0.22 * nodeOpacity).toFixed(2);
|
||||||
const gid = `fg-grad-${gi}`;
|
const coreOp = (0.7 * nodeOpacity).toFixed(2);
|
||||||
gi += 1;
|
const sw = (2.6 + n.strength * 1.4).toFixed(2);
|
||||||
defs.push(
|
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" opacity="${haloOp}"${dashAttr} />`);
|
||||||
`<linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}">`
|
parts.push(`<path d="${d}" fill="none" stroke="${col}" stroke-width="1.2" stroke-linecap="round" opacity="${coreOp}"${dashAttr} />`);
|
||||||
+ `<stop offset="0" stop-color="${FOCUS_NEON}" stop-opacity="0.42"/>`
|
|
||||||
+ `<stop offset="1" stop-color="${relationColor(n.relationType)}" stop-opacity="0.07"/></linearGradient>`
|
|
||||||
);
|
|
||||||
const sw = (1.0 + n.strength * 0.2).toFixed(2); // 1.0–1.2px
|
|
||||||
const op = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
|
|
||||||
parts.push(`<path d="${d}" fill="none" stroke="url(#${gid})" stroke-width="${sw}" stroke-linecap="round"${op}${dashAttr} />`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
|
edgesSvg.innerHTML = `<defs>${defs.join('')}</defs>${parts.join('')}`;
|
||||||
|
|||||||
150
shine-UI/js/pages/network/selftest.js
Normal file
150
shine-UI/js/pages/network/selftest.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
// Автопроверки интерактивного графа связей (dev-only).
|
||||||
|
//
|
||||||
|
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
|
||||||
|
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
|
||||||
|
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
|
||||||
|
//
|
||||||
|
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
|
||||||
|
|
||||||
|
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
|
||||||
|
|
||||||
|
export async function runNetworkSelfTest(graph, deepChipEl) {
|
||||||
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const results = [];
|
||||||
|
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
|
||||||
|
const st = () => graph.debugState();
|
||||||
|
|
||||||
|
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
|
||||||
|
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
|
||||||
|
await wait(1700);
|
||||||
|
|
||||||
|
let s = st();
|
||||||
|
const focusId = s.focusId;
|
||||||
|
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
|
||||||
|
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
|
||||||
|
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
|
||||||
|
|
||||||
|
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
|
||||||
|
graph.diveTo({ id: parent.id });
|
||||||
|
const framesA = graph.pumpForTest();
|
||||||
|
s = st();
|
||||||
|
const p = s.nodes.find((n) => n.id === parent.id);
|
||||||
|
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
|
||||||
|
|
||||||
|
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
|
||||||
|
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
|
||||||
|
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
|
||||||
|
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
|
||||||
|
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
|
||||||
|
|
||||||
|
// расталкивание: дети не слипаются
|
||||||
|
let minD = Infinity;
|
||||||
|
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
|
||||||
|
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
|
||||||
|
}
|
||||||
|
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
|
||||||
|
|
||||||
|
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
|
||||||
|
const outward = Math.atan2(p.y, p.x);
|
||||||
|
const maxDev = kids.reduce((mx, k) => {
|
||||||
|
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
|
||||||
|
if (d > Math.PI) d = 2 * Math.PI - d;
|
||||||
|
return Math.max(mx, d * 180 / Math.PI);
|
||||||
|
}, 0);
|
||||||
|
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
|
||||||
|
|
||||||
|
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
|
||||||
|
const offPath = tier1.filter((n) => n.id !== parent.id);
|
||||||
|
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
|
||||||
|
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
|
||||||
|
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
|
||||||
|
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
|
||||||
|
|
||||||
|
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
|
||||||
|
if (offPath.length) {
|
||||||
|
graph.diveTo({ id: offPath[0].id });
|
||||||
|
graph.pumpForTest();
|
||||||
|
s = st();
|
||||||
|
const prev = s.nodes.find((n) => n.id === parent.id);
|
||||||
|
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
|
||||||
|
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
|
||||||
|
const t2withKids = st().nodes.find((n) => n.tier === 2);
|
||||||
|
if (t2withKids) {
|
||||||
|
graph.diveTo({ id: t2withKids.id });
|
||||||
|
graph.pumpForTest();
|
||||||
|
s = st();
|
||||||
|
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
|
||||||
|
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
|
||||||
|
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
|
||||||
|
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
|
||||||
|
if (named && typeof graph.findNode === 'function') {
|
||||||
|
const byId = graph.findNode(named.id);
|
||||||
|
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
|
||||||
|
if (typeof graph.getDivePath === 'function' && t2withKids) {
|
||||||
|
const path = graph.getDivePath();
|
||||||
|
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
|
||||||
|
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
|
||||||
|
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
|
||||||
|
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) ===
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const commonCount = document.querySelectorAll('.fg-node.is-common').length;
|
||||||
|
check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест J: доступность — текстовый список графа для скринридеров ===
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const a11y = document.querySelector('.fg-a11y');
|
||||||
|
const liCount = a11y ? a11y.querySelectorAll('li').length : 0;
|
||||||
|
check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
|
||||||
|
graph.exitDive();
|
||||||
|
graph.pumpForTest();
|
||||||
|
s = st();
|
||||||
|
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
|
||||||
|
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
|
||||||
|
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
|
||||||
|
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
|
||||||
|
|
||||||
|
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const flare = document.querySelectorAll('.fg-plasma-flare');
|
||||||
|
const tube = document.querySelectorAll('.fg-plasma-tube');
|
||||||
|
const core = document.querySelectorAll('.fg-plasma-core');
|
||||||
|
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
|
||||||
|
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
|
||||||
|
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
|
||||||
|
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(results) {
|
||||||
|
const pass = results.filter((r) => r.pass).length;
|
||||||
|
const out = { pass, total: results.length, results };
|
||||||
|
if (typeof window !== 'undefined') window.__fgTestResults = out;
|
||||||
|
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
|
||||||
|
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@ -60,20 +60,38 @@
|
|||||||
transition: opacity 420ms ease; /* плавное появление линий при перестройке */
|
transition: opacity 420ms ease; /* плавное появление линий при перестройке */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Сияющая связь = двухслойный неоновый «световод» (Neon Layering): изящно, но объёмно (как OLED).
|
/* Сияющая связь = плазменный композитинг (3 слоя на одном S-пути, см. renderEdges). */
|
||||||
GLOW — широкий размытый ореол неонового оттенка под линией; CORE — тонкий чёткий светлый контур. */
|
.fg-plasma-flare {
|
||||||
.fg-edge-glow {
|
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: rgba(110, 225, 255, 1);
|
stroke: #00bfff;
|
||||||
stroke-width: 4;
|
stroke-width: 16;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
filter: blur(2px); /* мягкое объёмное свечение вокруг нити */
|
filter: url(#fg-plasma-blur6);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
animation: fg-plasma-breath 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.fg-edge-core {
|
.fg-plasma-tube {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #e0f7fc; /* ультра-светлый голубой — чёткий контур луча */
|
stroke: #00e5ff;
|
||||||
stroke-width: 1.5;
|
stroke-width: 6;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
|
filter: url(#fg-plasma-blur2);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
.fg-plasma-core {
|
||||||
|
fill: none;
|
||||||
|
stroke: #dffaff;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fg-plasma-breath {
|
||||||
|
0%, 100% { stroke-width: 14; }
|
||||||
|
50% { stroke-width: 19; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fg-plasma-flare { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-node {
|
.fg-node {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user