Worker orbs: cache glow sprites for perf + SoulSync logo in enrichment modal topbar

Performance:
- Bake one soft glow sprite per colour into an offscreen canvas and blit
  it with drawImage instead of allocating a radial gradient every frame.
  This was the hot path: sparks + inbound pulses + every orb glow each
  built a gradient per frame (100+/frame at 60fps). Colours quantised to
  8-step buckets to bound the cache (imperceptible tint shift, keeps the
  rainbow path from minting a sprite every frame).
- Cache each orb's button element at init so the 30-frame active-state
  check no longer re-runs querySelector.
- Net: the pulses/glows look identical, far fewer allocations per frame.

UI:
- Enrichment manager modal topbar icon now uses the SoulSync logo
  (trans2.png) instead of the helix emoji, matching the dashboard button.
This commit is contained in:
BoulderBadgeDad 2026-06-03 17:30:54 -07:00
parent 6dfe9f3d6a
commit 634b9ca0a0
3 changed files with 49 additions and 34 deletions

View file

@ -149,7 +149,7 @@ async function openEnrichmentManager(workerId) {
<div class="enrichment-manager-modal" role="dialog" aria-modal="true"
aria-label="Manage Enrichment Workers" tabindex="-1">
<div class="em-topbar">
<div class="em-topbar-icon"><span>🧬</span></div>
<div class="em-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="em-topbar-logo"></div>
<div class="em-topbar-titles">
<h3 class="em-topbar-title">Enrichment Workers</h3>
<div class="em-topbar-sub">Match your library across every metadata source</div>

View file

@ -64948,6 +64948,7 @@ body.reduce-effects .dash-card::after {
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.45), inset 0 1px 0 rgba(255,255,255,0.25);
}
.em-topbar-icon span { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); }
.em-topbar-logo { width: 32px; height: 32px; object-fit: contain; display: block; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); }
.em-topbar-titles { flex: 1 1 auto; min-width: 0; }
.em-topbar-title {
margin: 0; font-size: 19px; font-weight: 800; letter-spacing: 0.2px;

View file

@ -83,6 +83,7 @@
orbs.push({
el,
btn: el.matches('button') ? el : el.querySelector('button'),
color: def.color,
rainbow: def.rainbow || false,
hub: def.hub || false,
@ -161,6 +162,44 @@
];
}
// ── Glow sprite cache ──
// Radial gradients are the expensive part of canvas glows. Bake one soft
// glow sprite per colour into an offscreen canvas and blit it with
// drawImage — a single cheap GPU copy instead of allocating a gradient
// every frame. Colours are quantised to 8-step buckets to bound the cache
// (the tint shift is imperceptible in a glow, and keeps the rainbow path
// from minting a new sprite every frame).
const GLOW_SIZE = 64;
const _glowCache = new Map();
function getGlowSprite(r, g, b) {
const qr = r & ~7, qg = g & ~7, qb = b & ~7;
const key = (qr << 16) | (qg << 8) | qb;
let spr = _glowCache.get(key);
if (spr) return spr;
spr = document.createElement('canvas');
spr.width = spr.height = GLOW_SIZE;
const gctx = spr.getContext('2d');
const c = GLOW_SIZE / 2;
const grad = gctx.createRadialGradient(c, c, 0, c, c, c);
grad.addColorStop(0, `rgba(${qr}, ${qg}, ${qb}, 1)`);
grad.addColorStop(1, `rgba(${qr}, ${qg}, ${qb}, 0)`);
gctx.fillStyle = grad;
gctx.fillRect(0, 0, GLOW_SIZE, GLOW_SIZE);
_glowCache.set(key, spr);
return spr;
}
// Blit a cached glow of the given radius/alpha centred at (x, y)
function drawGlow(ctx, x, y, radius, r, g, b, alpha) {
if (alpha <= 0 || radius <= 0) return;
ctx.globalAlpha = alpha;
ctx.drawImage(getGlowSprite(r, g, b), x - radius, y - radius, radius * 2, radius * 2);
ctx.globalAlpha = 1;
}
// ── Spark system ──
function emitSpark(orb, colorOverride) {
@ -199,14 +238,8 @@
const alpha = s.life * 0.6;
const radius = s.radius * s.life;
// Spark glow
const glow = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius * 3);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${alpha * 0.4})`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(s.x, s.y, radius * 3, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
// Spark glow (cached sprite)
drawGlow(ctx, s.x, s.y, radius * 3, r, g, b, alpha * 0.4);
// Spark core
ctx.beginPath();
@ -248,13 +281,7 @@
const alpha = 0.55 * (1 - Math.abs(p.t - 0.5) * 0.6); // fade in/out at the ends
const radius = 2.2;
const glow = ctx.createRadialGradient(x, y, 0, x, y, radius * 3);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${alpha * 0.5})`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(x, y, radius * 3, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
drawGlow(ctx, x, y, radius * 3, r, g, b, alpha * 0.5);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
@ -420,12 +447,11 @@
return;
}
// Check active state every 30 frames
// Check active state every 30 frames (button ref is cached at init)
if (frameCount % 30 === 0) {
orbs.forEach(orb => {
orb.visible = orb.el.offsetParent !== null;
const btn = orb.el.querySelector('button');
orb.active = btn ? btn.classList.contains('active') : false;
orb.active = orb.btn ? orb.btn.classList.contains('active') : false;
});
}
@ -580,15 +606,9 @@
const slow = 0.5 + 0.5 * Math.sin(time * beatSpeed);
const hubR = (ORB_RADIUS + 3 + energy * 4) + slow * (2 + energy * 2);
// Wide ambient glow — brighter + wider with energy
// Wide ambient glow — brighter + wider with energy (cached sprite)
const glowR = hubR * (4 + energy * 1.5);
const halo = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, glowR);
halo.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${0.18 + energy * 0.18 + slow * 0.12})`);
halo.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(orb.x, orb.y, glowR, 0, Math.PI * 2);
ctx.fillStyle = halo;
ctx.fill();
drawGlow(ctx, orb.x, orb.y, glowR, r, g, b, 0.18 + energy * 0.18 + slow * 0.12);
if (hubImageReady) {
// SoulSync logo as the nucleus — fit to the pulsing radius while
@ -650,13 +670,7 @@
const glowAlpha = orb.active
? (0.25 + pulse * 0.2) * activeMult
: (0.06 + pulse * 0.03) * activeMult;
const glow = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, glowRadius);
glow.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${glowAlpha})`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(orb.x, orb.y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
drawGlow(ctx, orb.x, orb.y, glowRadius, r, g, b, glowAlpha);
// Core
const coreAlpha = orb.active