Связи: двухслойные линии-световоды, живой фон и стеклянные фильтры

- Сияющие связи — двухслойный неоновый «световод»: размытый glow (4px, blur 2px,
  opacity 0.4) + тонкий чёткий core (1.5px, #e0f7fc). Объёмное OLED-свечение,
  линия остаётся изящной. Оба слоя растут синхронно (общий dashoffset).
- Обычные линии — тоньше (1.0–1.2px) и глубокий уход в прозрачность (0.42 → 0.07),
  чтобы матовые связи не спорили с сияющими.
- Живой фон-«небула»: глубокое размытое сине-голубое облако под центром, медленная
  пульсация радиуса/яркости + переливы индиго↔ультрамарин (hue-rotate, 7с).
- Стеклянные чипы фильтров (frosted glass): rgba(255,255,255,0.03) + backdrop blur(12px)
  + граница 0.5px solid rgba(255,255,255,0.1); активный подсвечен сине-голубым.
- Бамп client.version → 1.2.138; документация фичи обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-09 21:23:17 +03:00
co-authored by Claude Opus 4.8
parent 9ee6bf4380
commit dc96033cb1
4 changed files with 89 additions and 40 deletions
+26 -22
View File
@@ -53,8 +53,6 @@ const RELATION_COLORS = {
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
// Яркий неон сияния — в него уходит градиент связи к «сияющему» узлу (совпадает со свечением аватарки).
const SHINE_EDGE_NEON = 'rgba(150, 245, 255, 0.95)';
function easeOutCubic(t) {
const x = 1 - t;
@@ -416,26 +414,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const desY = my + ux * baseBow + invY * lag;
const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired
const cpy = 2 * desY - my;
// ТОНКАЯ изящная дуга: одинарная квадратичная кривая Безье, лёгкий градиентный штрих.
// Обычная связь — неон у центра → цвет роли у узла. Связь к «СИЯЮЩЕМУ» — ярче, уходит в
// неон сияния и МОНОЛИТНО светится (статичный drop-shadow через класс .fg-edge-shine).
// Связь рисуем по статусу узла:
// обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность;
// • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий
// core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core).
const shine = Boolean(n.shining) && !n.hidden;
const gid = `fg-grad-${gi}`;
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 = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
// ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла
// (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра.
let dashAttr = '';
if (growing) {
const finalD = Math.hypot(n.bfx, n.bfy) || 1;
@@ -444,8 +430,26 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
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)}"`;
}
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} />`);
if (shine) {
// glow (размытый, приглушённый) + core (тонкий, чёткий) — растут синхронно (общий dashAttr)
const gOp = (0.4 * nodeOpacity).toFixed(2);
const cOpAttr = nodeOpacity < 0.995 ? ` opacity="${nodeOpacity.toFixed(2)}"` : '';
parts.push(`<path class="fg-edge-glow" d="${d}"${dashAttr} opacity="${gOp}" />`);
parts.push(`<path class="fg-edge-core" d="${d}"${dashAttr}${cOpAttr} />`);
} else {
// обычная: тонкая дуга, градиент 0.42 (центр) → 0.07 (аватарка) — глубокий уход в прозрачность,
// чтобы матовые связи не спорили с сияющими.
const gid = `fg-grad-${gi}`;
gi += 1;
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="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.01.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('')}`;
}