Связи (test 12.06): SVG-стеклянные орбы аватаров + цвет/свечение линий глубоких связей

Аватары → SVG GlassOrb (фото в стеклянной сфере, блик, rim, свечение). Линии глубоких связей (tier-2/3) — в цвете типа (друзья/семья/...), сияющие связи светятся (голубой ореол + ядро). Версия 1.2.159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel 2026-06-12 14:10:06 +03:00
parent 2559f1e66b
commit 652ddc9d88
3 changed files with 86 additions and 17 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.158
server.version=1.2.142
client.version=1.2.159
server.version=1.2.143

View File

@ -419,6 +419,45 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return wrap;
}
// SVG-«стеклянный орб» для аватара (фото в стеклянной сфере ≈90% + блик + rim + свечение).
// Уникальные id на экземпляр (иначе defs конфликтуют). Тело стекла тёмно-синее/прозрачное (без серости),
// вторичный блик убран (был «звёздочкой»). Фолбэк: если фото не загрузилось — остаются инициалы.
let orbSeq = 0;
function buildGlassOrb(src, opts) {
const o = opts || {};
const u = 'o' + (orbSeq += 1);
const glowOp = o.isFocus ? 0.34 : 0.28;
const imgFilter = o.isFocus ? 'grayscale(0.9) contrast(1.04)' : 'saturate(0.85) brightness(0.97)';
const init = String(o.initials || '').slice(0, 2);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 100 100');
svg.setAttribute('class', 'fg-orb-svg');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = ''
+ '<defs>'
+ '<clipPath id="c' + u + '"><circle cx="50" cy="50" r="38"/></clipPath>'
+ '<radialGradient id="sh' + u + '" cx="50%" cy="45%" r="52%"><stop offset="62%" stop-color="#bfe3ff" stop-opacity="0"/><stop offset="90%" stop-color="#dff1ff" stop-opacity="0.55"/><stop offset="100%" stop-color="#5f93b8" stop-opacity="0.25"/></radialGradient>'
+ '<radialGradient id="sd' + u + '" cx="50%" cy="60%" r="55%"><stop offset="58%" stop-color="#000000" stop-opacity="0"/><stop offset="100%" stop-color="#02060d" stop-opacity="0.4"/></radialGradient>'
+ '<linearGradient id="sp' + u + '" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#ffffff" stop-opacity="0.85"/><stop offset="100%" stop-color="#ffffff" stop-opacity="0"/></linearGradient>'
+ '<filter id="so' + u + '" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="1.3"/></filter>'
+ '<filter id="gl' + u + '" x="-70%" y="-70%" width="240%" height="240%"><feGaussianBlur stdDeviation="4.5"/></filter>'
+ '</defs>'
+ '<circle cx="50" cy="50" r="42" fill="#5facd4" opacity="' + glowOp + '" filter="url(#gl' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="#0a1626" opacity="0.38"/>'
+ '<circle cx="50" cy="50" r="38" fill="#26344a"/>'
+ (init ? '<text x="50" y="58" text-anchor="middle" fill="#cfe0ff" font-family="sans-serif" font-weight="600" font-size="22">' + init + '</text>' : '')
+ (src ? '<image href="' + src + '" x="12" y="12" width="76" height="76" clip-path="url(#c' + u + ')" preserveAspectRatio="xMidYMid slice" style="filter:' + imgFilter + '"/>' : '')
+ '<circle cx="50" cy="50" r="38" fill="url(#sd' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="url(#sh' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="#2f7fd0" opacity="0.07"/>'
+ '<path d="M74 16 A42 42 0 0 1 90 61" fill="none" stroke="#9fd2f2" stroke-width="1.6" stroke-opacity="0.85" stroke-linecap="round" filter="url(#so' + u + ')"/>'
+ '<circle cx="50" cy="50" r="42" fill="none" stroke="#a9d6f0" stroke-width="0.6" stroke-opacity="0.5"/>'
+ '<ellipse cx="37" cy="30" rx="16" ry="8" fill="url(#sp' + u + ')" transform="rotate(-28 37 30)" filter="url(#so' + u + ')"/>';
const im = svg.querySelector('image');
if (im) im.addEventListener('error', () => { try { im.remove(); } catch (e) { /* останутся инициалы */ } });
return svg;
}
function buildNodeElement(src, isFocus, tier, dotOnly = false) {
const el = document.createElement('button');
el.type = 'button';
@ -447,17 +486,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id);
// тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы)
const avatar = src.photo
? buildPhotoAvatar(src)
: renderUserAvatar({
login: src.login || src.name || String(src.id),
firstName: src.name || '',
avatar: src.avatar || null,
size: 'node',
title: src.name || src.login || '',
});
el.append(avatar);
// Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
// синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
const photoSrc = src.photo || (src.avatar && src.avatar !== 'url_to_image' ? src.avatar : null);
const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
const dot = document.createElement('div');
dot.className = 'avatar node-dot fg-orb-host';
dot.appendChild(buildGlassOrb(photoSrc, { isFocus, initials }));
el.append(dot);
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
const badge = document.createElement('span');
@ -772,11 +808,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) {
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe * sp).toFixed(2)}" />`);
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
}
}
} else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom)
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe * sp).toFixed(2)}" />`);
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
}
}
} else if (shine || n.track || onPath) {
// СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя
// с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро.

View File

@ -128,6 +128,25 @@
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
}
/* SVG-«стеклянный орб» масштабируем так, чтобы сфера (r42 = 84% SVG) диаметр узла линии-связи
прилипают к краю орба, как раньше. Хост .node-dot держит размер/состояния/синхронизацию позиций. */
.fg-node .node-dot.fg-orb-host {
position: relative;
background: none;
overflow: visible; /* не срезать внешнее свечение орба */
box-shadow: none;
}
.fg-orb-svg {
position: absolute;
width: 119%;
height: 119%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: block;
overflow: visible;
}
.fg-node.is-family .node-dot {
background: linear-gradient(165deg, #785038, #5f3e2c);
border-color: rgba(255, 194, 143, 0.6);