You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/webui/static/worker-orbs.js

953 lines
36 KiB

// ============================================================
// SoulSync — Worker Orbs
// Dashboard header buttons shrink to floating orbs, expand on hover
// ============================================================
(function () {
'use strict';
// Disable on mobile
if (window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return;
// ── Worker definitions with brand colors ──
const WORKER_DEFS = [
{ container: '.mb-button-container', color: [186, 85, 211], id: 'musicbrainz' },
{ container: '.audiodb-button-container', color: [0, 188, 212], id: 'audiodb' },
{ container: '.deezer-button-container', color: [162, 56, 255], id: 'deezer' },
{ container: '.spotify-enrich-button-container', color: [30, 215, 96], id: 'spotify-enrichment' },
{ container: '.itunes-enrich-button-container', color: [251, 91, 137], id: 'itunes-enrichment' },
{ container: '.lastfm-enrich-button-container', color: [213, 16, 7], id: 'lastfm-enrichment' },
{ container: '.genius-enrich-button-container', color: [255, 255, 100], id: 'genius-enrichment' },
{ container: '.tidal-enrich-button-container', color: [180, 180, 255], id: 'tidal-enrichment' },
{ container: '.qobuz-enrich-button-container', color: [1, 112, 239], id: 'qobuz-enrichment' },
{ container: '.discogs-button-container', color: [180, 180, 180], id: 'discogs' },
{ container: '.amazon-enrich-button-container', color: [255, 153, 0], id: 'amazon-enrichment' },
{ container: '.similar-artists-enrich-button-container', color: [168, 85, 247], id: 'similar_artists' },
{ container: '.hydrabase-button-container', color: [200, 200, 200], id: 'hydrabase' },
{ container: '.soulid-button-container', color: [29, 185, 84], rainbow: true, id: 'soulid' },
{ container: '.repair-button-container', color: [180, 130, 255], rainbow: true, id: 'repair' },
{ container: '.em-manage-btn', color: [168, 85, 247], hub: true },
];
const ERROR_COLOR = [255, 80, 80]; // pulses fired on real worker errors
const PULSE_CAP = 12; // max pulses queued per status update
// Status pushes arrive ~every 2s (120 frames). Spread each window's pulses
// across that interval so they drip steadily instead of bursting on arrival.
const STATUS_FRAMES = 120;
const MIN_RELEASE_RATE = 1 / 45; // a lone event still appears within ~0.75s
const ORB_RADIUS = 7;
const ORB_DIAMETER = ORB_RADIUS * 2;
const CONNECTION_DIST = 70;
const LERP_SPEED = 0.08;
const EXPAND_STAGGER = 35;
const MAX_SPARKS = 60; // global spark pool cap
const SPARK_RATE = 0.12; // chance per frame per active orb to emit
const MAX_INFLOWS = 48; // hub inbound-pulse pool cap
const INFLOW_RATE = 0.05; // chance per frame per active orb to send a pulse inward
let dashboardHeader = null;
let headerActions = null;
let canvas = null;
let ctx = null;
let orbs = [];
let sparks = []; // particle emissions from active orbs
let inflows = []; // pulses traveling from active orbs into the hub
let errorHeat = 0; // 0..1 aggregate "stress" — bumps on real worker errors, decays over time
let state = 'idle';
let animFrame = null;
let onDashboard = false;
let expandProgress = 0;
let staggerTimers = [];
let collapseDelay = null;
const COLLAPSE_DELAY_MS = 7000;
// SoulSync logo, drawn as the hub/nucleus once loaded
let hubImage = null;
let hubImageReady = false;
// ── Init ──
function init() {
dashboardHeader = document.querySelector('#dashboard-page .dashboard-header');
headerActions = document.querySelector('#dashboard-page .header-actions');
if (!dashboardHeader || !headerActions) return;
if (!hubImage) {
hubImage = new Image();
hubImage.onload = () => { hubImageReady = true; };
hubImage.src = '/static/trans2.png';
}
canvas = document.createElement('canvas');
canvas.className = 'worker-orb-canvas';
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;';
dashboardHeader.appendChild(canvas);
ctx = canvas.getContext('2d');
WORKER_DEFS.forEach((def, i) => {
const el = headerActions.querySelector(def.container);
if (!el) return;
orbs.push({
el,
btn: el.matches('button') ? el : el.querySelector('button'),
id: def.id || null,
color: def.color,
rainbow: def.rainbow || false,
hub: def.hub || false,
index: i,
x: 0, y: 0,
vx: (Math.random() - 0.5) * 0.6,
vy: (Math.random() - 0.5) * 0.6,
homeX: 0, homeY: 0,
visible: true,
phase: Math.random() * Math.PI * 2,
active: false,
statusSeen: false, // has a real WS status arrived for this worker?
lastProcessed: 0, // cumulative matched+not_found seen last update
lastErrors: 0, // cumulative error count seen last update
pendingWork: 0, // brand-colour pulses still to release
pendingErr: 0, // red pulses still to release (real errors)
workRate: 0, // pulses/frame, set so pending drains over the interval
errRate: 0,
workCarry: 0, // fractional-pulse accumulators
errCarry: 0,
});
});
computeHomes();
scatterOrbs();
dashboardHeader.addEventListener('mouseenter', onMouseEnter);
dashboardHeader.addEventListener('mouseleave', onMouseLeave);
window.addEventListener('resize', onResize);
document.addEventListener('visibilitychange', onVisibility);
}
function computeHomes() {
if (!dashboardHeader || !headerActions) return;
const headerRect = dashboardHeader.getBoundingClientRect();
orbs.forEach(orb => {
const elRect = orb.el.getBoundingClientRect();
orb.homeX = (elRect.left - headerRect.left) + elRect.width / 2;
orb.homeY = (elRect.top - headerRect.top) + elRect.height / 2;
orb.visible = orb.el.offsetParent !== null;
});
}
function scatterOrbs() {
if (!canvas) return;
const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 80;
orbs.forEach(orb => {
if (!orb.visible) return;
orb.x = ORB_RADIUS + Math.random() * (w - ORB_DIAMETER);
orb.y = ORB_RADIUS + Math.random() * (h - ORB_DIAMETER);
});
}
function resizeCanvas() {
if (!canvas) return;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
}
// ── Rainbow color cycle (matches repair button's CSS rainbow) ──
const RAINBOW = [
[255, 0, 0],
[255, 136, 0],
[255, 255, 0],
[0, 255, 0],
[0, 136, 255],
[136, 0, 255],
];
function getRainbowColor(time) {
const t = ((time * 0.33) % 1 + 1) % 1; // ~3s cycle to match CSS 3s
const idx = t * RAINBOW.length;
const i = Math.floor(idx);
const f = idx - i;
const a = RAINBOW[i % RAINBOW.length];
const b = RAINBOW[(i + 1) % RAINBOW.length];
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f),
];
}
// ── 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) {
if (sparks.length >= MAX_SPARKS) return;
const angle = Math.random() * Math.PI * 2;
const speed = 0.4 + Math.random() * 0.8;
sparks.push({
x: orb.x,
y: orb.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0, // 1.0 → 0.0
decay: 0.012 + Math.random() * 0.012,
color: colorOverride || orb.color,
radius: 1.5 + Math.random() * 1.5,
});
}
function updateSparks() {
for (let i = sparks.length - 1; i >= 0; i--) {
const s = sparks[i];
s.x += s.vx;
s.y += s.vy;
s.vx *= 0.98;
s.vy *= 0.98;
s.life -= s.decay;
if (s.life <= 0) {
sparks.splice(i, 1);
}
}
}
function drawSparks(ctx) {
for (const s of sparks) {
const [r, g, b] = s.color;
const alpha = s.life * 0.6;
const radius = s.radius * s.life;
// Spark glow (cached sprite)
drawGlow(ctx, s.x, s.y, radius * 3, r, g, b, alpha * 0.4);
// Spark core
ctx.beginPath();
ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.fill();
}
}
// ── Inbound pulses (active worker → hub) ──
// Each carries an active worker's color into the nucleus, so the hub
// visibly "collects" the output of whatever is running.
function emitInflow(orb, color) {
if (inflows.length >= MAX_INFLOWS) return;
inflows.push({
orb, // source orb (positions resolved live)
color: color || orb.color,
t: 0, // 0 at source → 1 at hub
speed: 0.012 + Math.random() * 0.01,
});
}
function updateInflows() {
for (let i = inflows.length - 1; i >= 0; i--) {
inflows[i].t += inflows[i].speed;
if (inflows[i].t >= 1) inflows.splice(i, 1);
}
}
function drawInflows(ctx, hub) {
if (!hub) return;
for (const p of inflows) {
const [r, g, b] = p.color;
// Ease toward hub so pulses accelerate as they arrive
const e = p.t * p.t;
const x = p.orb.x + (hub.x - p.orb.x) * e;
const y = p.orb.y + (hub.y - p.orb.y) * e;
const alpha = 0.55 * (1 - Math.abs(p.t - 0.5) * 0.6); // fade in/out at the ends
const radius = 2.2;
drawGlow(ctx, x, y, radius * 3, r, g, b, alpha * 0.5);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.fill();
}
}
// ── State machine ──
function enterOrbState() {
if (state === 'orbs') return;
state = 'orbs';
expandProgress = 0;
orbs.forEach(orb => {
orb.el.classList.add('worker-orb-hidden');
});
canvas.style.opacity = '1';
canvas.style.display = '';
resizeCanvas();
startLoop();
}
function enterExpandedState() {
state = 'expanded';
expandProgress = 1;
clearStaggerTimers();
orbs.forEach((orb, i) => {
const t = setTimeout(() => {
orb.el.classList.remove('worker-orb-hidden');
orb.el.classList.add('worker-orb-reveal');
}, i * EXPAND_STAGGER);
staggerTimers.push(t);
});
canvas.style.opacity = '0';
setTimeout(() => {
if (state === 'expanded') {
canvas.style.display = 'none';
stopLoop();
}
}, 400);
}
function enterCollapsingState() {
state = 'collapsing';
clearStaggerTimers();
const total = orbs.length;
orbs.forEach((orb, i) => {
const t = setTimeout(() => {
orb.el.classList.remove('worker-orb-reveal');
orb.el.classList.add('worker-orb-hidden');
}, (total - 1 - i) * 20);
staggerTimers.push(t);
});
canvas.style.display = '';
canvas.style.opacity = '1';
resizeCanvas();
computeHomes();
inflows = []; // drop in-flight pulses; positions are about to jump
orbs.forEach(orb => {
orb.x = orb.homeX;
orb.y = orb.homeY;
orb.vx = (Math.random() - 0.5) * 0.4;
orb.vy = (Math.random() - 0.5) * 0.4;
});
startLoop();
setTimeout(() => {
if (state === 'collapsing') {
state = 'orbs';
expandProgress = 0;
}
}, total * 20 + 100);
}
function clearStaggerTimers() {
staggerTimers.forEach(t => clearTimeout(t));
staggerTimers = [];
}
// ── Events ──
function onMouseEnter() {
if (!onDashboard) return;
// Cancel any pending collapse
if (collapseDelay) {
clearTimeout(collapseDelay);
collapseDelay = null;
}
if (state === 'orbs' || state === 'collapsing') {
state = 'expanding';
expandProgress = 0;
}
}
function onMouseLeave() {
if (!onDashboard) return;
if (state === 'expanded' || state === 'expanding') {
// Delay before collapsing back to orbs
if (collapseDelay) clearTimeout(collapseDelay);
collapseDelay = setTimeout(() => {
collapseDelay = null;
if (state === 'expanded' || state === 'expanding') {
enterCollapsingState();
}
}, COLLAPSE_DELAY_MS);
}
}
function onResize() {
computeHomes();
resizeCanvas();
const w = canvas ? canvas.width : 600;
const h = canvas ? canvas.height : 80;
orbs.forEach(orb => {
orb.x = Math.max(ORB_RADIUS, Math.min(w - ORB_RADIUS, orb.x));
orb.y = Math.max(ORB_RADIUS, Math.min(h - ORB_RADIUS, orb.y));
});
}
function onVisibility() {
if (document.hidden) {
stopLoop();
} else if (onDashboard && (state === 'orbs' || state === 'expanding' || state === 'collapsing')) {
startLoop();
}
}
// ── Animation loop ──
let frameCount = 0;
let _scrollPauseUntil = 0;
(function attachScrollPause() {
const scroller = document.querySelector('.main-content') || window;
scroller.addEventListener('scroll', () => {
_scrollPauseUntil = performance.now() + 180;
}, { passive: true });
})();
function startLoop() {
if (animFrame) return;
tick();
}
function stopLoop() {
if (animFrame) {
cancelAnimationFrame(animFrame);
animFrame = null;
}
}
function tick() {
animFrame = requestAnimationFrame(tick);
if (!canvas || !ctx) return;
// Yield the frame to active scrolling (orbs freeze, resume on idle).
if (performance.now() < _scrollPauseUntil) return;
frameCount++;
const time = frameCount / 60;
const w = canvas.width;
const h = canvas.height;
if (w === 0 || h === 0) {
resizeCanvas();
return;
}
// Health stress cools off when errors stop (~6s to settle from a spike)
if (errorHeat > 0.0001) errorHeat *= 0.992; else errorHeat = 0;
// 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;
orb.active = orb.btn ? orb.btn.classList.contains('active') : false;
});
}
const visibleOrbs = orbs.filter(o => o.visible);
const hub = visibleOrbs.find(o => o.hub);
if (state === 'orbs' || state === 'collapsing') {
updatePhysics(visibleOrbs, w, h);
} else if (state === 'expanding') {
updateExpanding(visibleOrbs, w, h);
}
// Sparks (ambient aura while active) + inbound pulses to the hub.
// Pulses are event-driven: one per real item matched / error reported,
// drained a couple per frame so bursts stagger nicely up the spoke.
for (const orb of visibleOrbs) {
if (orb.hub) continue;
if (orb.active && Math.random() < SPARK_RATE) {
emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null);
}
if (!hub) continue;
if (orb.statusSeen) {
// Release queued pulses at a steady drip so a 2s window of
// events streams up the spoke instead of arriving all at once.
if (orb.pendingWork > 0) {
orb.workCarry += orb.workRate;
while (orb.workCarry >= 1 && orb.pendingWork > 0) {
emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null);
orb.workCarry -= 1; orb.pendingWork -= 1;
}
} else {
orb.workCarry = 0;
}
if (orb.pendingErr > 0) {
orb.errCarry += orb.errRate;
while (orb.errCarry >= 1 && orb.pendingErr > 0) {
emitInflow(orb, ERROR_COLOR);
orb.errCarry -= 1; orb.pendingErr -= 1;
}
} else {
orb.errCarry = 0;
}
} else if (orb.active && Math.random() < INFLOW_RATE) {
// No real status yet — keep the old ambient trickle as fallback
emitInflow(orb, orb.rainbow ? getRainbowColor(time) : null);
}
}
updateSparks();
updateInflows();
// Draw
ctx.clearRect(0, 0, w, h);
drawConnections(ctx, visibleOrbs, time);
drawSparks(ctx);
drawInflows(ctx, hub);
drawOrbs(ctx, visibleOrbs, time);
}
// ── Physics ──
function updatePhysics(visible, w, h) {
const cx = w * 0.5;
const cy = h * 0.5;
for (const orb of visible) {
// The hub is a nucleus — it settles at canvas center and stays put
// while every worker orb drifts around it. No jitter, strong pull home.
if (orb.hub) {
orb.vx += (cx - orb.x) * 0.02;
orb.vy += (cy - orb.y) * 0.02;
orb.vx *= 0.85;
orb.vy *= 0.85;
orb.x += orb.vx;
orb.y += orb.vy;
continue;
}
// Active orbs drift faster
const driftStrength = orb.active ? 0.04 : 0.02;
orb.vx += (Math.random() - 0.5) * driftStrength;
orb.vy += (Math.random() - 0.5) * driftStrength;
// Subtle gravity toward center — keeps orbs loosely grouped
const gx = cx - orb.x;
const gy = cy - orb.y;
const gDist = Math.sqrt(gx * gx + gy * gy);
if (gDist > 1) {
const gStrength = 0.004;
orb.vx += (gx / gDist) * gStrength;
orb.vy += (gy / gDist) * gStrength;
// Orbital rotation — a tangential nudge (perpendicular to the
// pull home) so the cluster slowly revolves around the nucleus
// like electrons round an atom. Stronger when the orb is active.
const tStrength = orb.active ? 0.008 : 0.005;
orb.vx += (-gy / gDist) * tStrength;
orb.vy += (gx / gDist) * tStrength;
}
// Damping
orb.vx *= 0.993;
orb.vy *= 0.993;
// Speed cap — active orbs move a bit faster
const maxSpeed = orb.active ? 0.8 : 0.5;
const speed = Math.sqrt(orb.vx * orb.vx + orb.vy * orb.vy);
if (speed > maxSpeed) {
const scale = maxSpeed / speed;
orb.vx *= scale;
orb.vy *= scale;
}
// Soft repulsion from other orbs
for (const other of visible) {
if (other === orb) continue;
const dx = orb.x - other.x;
const dy = orb.y - other.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 35 && dist > 0.1) {
const force = 0.03 * (1 - dist / 35);
orb.vx += (dx / dist) * force;
orb.vy += (dy / dist) * force;
}
}
// Move
orb.x += orb.vx;
orb.y += orb.vy;
// Boundary bounce
if (orb.x < ORB_RADIUS) { orb.x = ORB_RADIUS; orb.vx *= -0.7; }
if (orb.x > w - ORB_RADIUS) { orb.x = w - ORB_RADIUS; orb.vx *= -0.7; }
if (orb.y < ORB_RADIUS) { orb.y = ORB_RADIUS; orb.vy *= -0.7; }
if (orb.y > h - ORB_RADIUS) { orb.y = h - ORB_RADIUS; orb.vy *= -0.7; }
}
}
function updateExpanding(visible) {
let allClose = true;
for (const orb of visible) {
const dx = orb.homeX - orb.x;
const dy = orb.homeY - orb.y;
orb.x += dx * LERP_SPEED;
orb.y += dy * LERP_SPEED;
orb.vx *= 0.9;
orb.vy *= 0.9;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 3) allClose = false;
}
expandProgress = Math.min(1, expandProgress + 0.03);
if (allClose || expandProgress >= 1) {
enterExpandedState();
}
}
// ── Drawing ──
function drawOrbs(ctx, visible, time) {
for (const orb of visible) {
const [r, g, b] = orb.rainbow ? getRainbowColor(time) : orb.color;
// ── The hub: an energy-reactive nucleus ──
// Calm + dim when nothing's running; bigger, brighter and faster
// the more workers are active. The animation reads as a gauge.
if (orb.hub) {
const workers = visible.filter(o => !o.hub);
const activeCount = workers.filter(o => o.active).length;
const energy = workers.length ? activeCount / workers.length : 0; // 0..1
const stress = errorHeat; // 0..1 health gauge
// Health shows as a gentle, gradual warm-red shift in the
// nucleus — never a fast flicker. Stress does NOT speed up the
// heartbeat (that read as jitter); only the colour eases over.
const beatSpeed = 1.0 + energy * 1.4;
const slow = 0.5 + 0.5 * Math.sin(time * beatSpeed);
// Barely-there breathing — the nucleus is mostly steady
const hubR = (ORB_RADIUS + 3 + energy * 4) + slow * (0.6 + energy * 0.8);
const tint = stress * 0.55; // softened, never full alarm-red
const hr = Math.round(r + (235 - r) * tint);
const hg = Math.round(g + (60 - g) * tint);
const hb = Math.round(b + (60 - b) * tint);
// Wide ambient glow — steady, only gently lifting with energy
const glowR = hubR * (4 + energy * 1.5);
drawGlow(ctx, orb.x, orb.y, glowR, hr, hg, hb, 0.16 + energy * 0.16 + slow * 0.04 + stress * 0.08);
if (hubImageReady) {
// SoulSync logo as the nucleus — fit to the pulsing radius while
// preserving the image's natural aspect ratio (no stretch)
const natW = hubImage.naturalWidth || 1;
const natH = hubImage.naturalHeight || 1;
const fit = (hubR * 3.2) / Math.max(natW, natH);
const dw = natW * fit;
const dh = natH * fit;
ctx.save();
ctx.globalAlpha = Math.min(1, 0.9 + energy * 0.1 + slow * 0.03);
ctx.drawImage(hubImage, orb.x - dw / 2, orb.y - dh / 2, dw, dh);
ctx.restore();
} else {
// Fallback while the logo loads: solid bright core + highlight
ctx.beginPath();
ctx.arc(orb.x, orb.y, hubR, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${0.8 + energy * 0.15})`;
ctx.fill();
ctx.beginPath();
ctx.arc(orb.x, orb.y, hubR * 0.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${0.3 + energy * 0.25 + slow * 0.2})`;
ctx.fill();
}
// A single, very faint expanding ring — only when workers are
// actually busy, and barely visible so it reads as a soft halo,
// not a throbbing pulse.
if (energy > 0.02) {
const ringPhase = (time * 0.35) % 1;
const ringR = hubR + ringPhase * hubR * 1.4;
ctx.beginPath();
ctx.arc(orb.x, orb.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${(1 - ringPhase) * 0.08 * energy})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Health warning: a single soft red ring that breathes slowly
// (no flicker) and fades in/out gradually as stress rises/cools.
if (stress > 0.04) {
const warn = 0.5 + 0.5 * Math.sin(time * 1.4);
const wr = hubR + 3 + warn * 3;
ctx.beginPath();
ctx.arc(orb.x, orb.y, wr, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255, 90, 90, ${stress * (0.12 + warn * 0.10)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
continue;
}
const pulse = 0.5 + 0.5 * Math.sin(time * 2 + orb.phase);
// Active orbs are larger and breathe — size oscillates
let baseRadius = orb.active ? ORB_RADIUS + 3 : ORB_RADIUS;
if (orb.active) {
baseRadius += 2 * Math.sin(time * 3 + orb.phase);
}
// Scale up during expand transition
const currentRadius = state === 'expanding'
? baseRadius + expandProgress * 4
: baseRadius;
// Inactive orbs are dimmer
const activeMult = orb.active ? 1.0 : 0.45;
// Outer glow — much larger and brighter for active
const glowRadius = orb.active ? currentRadius * 5 : currentRadius * 3;
const glowAlpha = orb.active
? (0.25 + pulse * 0.2) * activeMult
: (0.06 + pulse * 0.03) * activeMult;
drawGlow(ctx, orb.x, orb.y, glowRadius, r, g, b, glowAlpha);
// Core
const coreAlpha = orb.active
? 0.85 + pulse * 0.15
: (0.3 + pulse * 0.08) * activeMult;
ctx.beginPath();
ctx.arc(orb.x, orb.y, Math.max(1, currentRadius), 0, Math.PI * 2);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${coreAlpha})`;
ctx.fill();
// Inactive: subtle border ring so they're visible against dark backgrounds
if (!orb.active) {
ctx.beginPath();
ctx.arc(orb.x, orb.y, currentRadius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.3 + pulse * 0.1})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Active: expanding pulse ring that fades
if (orb.active) {
// Inner ring — tight, bright
const ring1 = currentRadius + 2 + pulse * 3;
ctx.beginPath();
ctx.arc(orb.x, orb.y, ring1, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.25 + pulse * 0.15})`;
ctx.lineWidth = 1;
ctx.stroke();
// Outer ring — wide, faint, slower pulse
const pulse2 = 0.5 + 0.5 * Math.sin(time * 1.2 + orb.phase + 1);
const ring2 = currentRadius + 6 + pulse2 * 6;
ctx.beginPath();
ctx.arc(orb.x, orb.y, ring2, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.06 + pulse2 * 0.06})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
function drawConnections(ctx, visible, time) {
// Hub spokes — the nucleus is wired to every worker orb, full length,
// so it always reads as the center that "manages" the cluster.
const hub = visible.find(o => o.hub);
if (hub) {
const [hr, hg, hb] = hub.color;
for (const orb of visible) {
if (orb === hub) continue;
const dx = hub.x - orb.x;
const dy = hub.y - orb.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
// Gentle traveling pulse along each spoke
const flow = 0.5 + 0.5 * Math.sin(time * 2 - dist * 0.05);
const alpha = 0.10 + flow * 0.10 + (orb.active ? 0.06 : 0);
ctx.beginPath();
ctx.moveTo(hub.x, hub.y);
ctx.lineTo(orb.x, orb.y);
ctx.strokeStyle = `rgba(${hr}, ${hg}, ${hb}, ${alpha})`;
ctx.lineWidth = orb.active ? 1.0 : 0.6;
ctx.stroke();
}
}
for (let i = 0; i < visible.length; i++) {
for (let j = i + 1; j < visible.length; j++) {
const a = visible[i], b = visible[j];
if (a.hub || b.hub) continue; // hub spokes handled above
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECTION_DIST) {
// Connections between active orbs are brighter
const activePair = a.active && b.active;
const anyActive = a.active || b.active;
const baseAlpha = activePair ? 0.3 : (anyActive ? 0.2 : 0.15);
const alpha = (1 - dist / CONNECTION_DIST) * baseAlpha;
const [r1, g1, b1] = a.rainbow ? getRainbowColor(time) : a.color;
const [r2, g2, b2] = b.rainbow ? getRainbowColor(time) : b.color;
const mr = (r1 + r2) >> 1;
const mg = (g1 + g2) >> 1;
const mb = (b1 + b2) >> 1;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(${mr}, ${mg}, ${mb}, ${alpha})`;
ctx.lineWidth = anyActive ? 0.8 : 0.5;
ctx.stroke();
}
}
}
}
// ── Page awareness ──
function isEnabled() {
return window._workerOrbsEnabled !== false && !window._reduceEffectsActive;
}
// ── Real telemetry → pulses ──
// Fed by the WebSocket enrichment status pushes (see core.js). We diff the
// cumulative counters between updates and queue one inbound pulse per real
// item processed (brand colour) or error (red). No status yet → the loop
// falls back to an ambient trickle so active orbs still animate.
function onStatus(id, data) {
if (!id || !data) return;
const orb = orbs.find(o => o.id === id);
if (!orb) return;
const s = data.stats || {};
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
// "processed" = every flavour of completed item across the worker zoo
const processed = num(s.matched) + num(s.not_found) + num(s.repaired)
+ num(s.synced) + num(s.scanned);
const errors = num(s.errors);
if (!orb.statusSeen) {
// First sample is just a baseline — don't dump the whole backlog
orb.statusSeen = true;
orb.lastProcessed = processed;
orb.lastErrors = errors;
return;
}
const dWork = processed - orb.lastProcessed;
const dErr = errors - orb.lastErrors;
orb.lastProcessed = processed;
orb.lastErrors = errors;
// Queue the new events and (re)set a drip rate that empties the current
// backlog over the interval until the next push — steady stream, not a burst.
if (dWork > 0) {
orb.pendingWork = Math.min(PULSE_CAP, orb.pendingWork + dWork);
orb.workRate = Math.max(MIN_RELEASE_RATE, orb.pendingWork / STATUS_FRAMES);
}
if (dErr > 0) {
orb.pendingErr = Math.min(PULSE_CAP, orb.pendingErr + dErr);
orb.errRate = Math.max(MIN_RELEASE_RATE, orb.pendingErr / STATUS_FRAMES);
// Feed the nucleus health gauge — each real error eases the hub's
// stress up gradually (404s are not_found now, so this only fires on
// true failures). Small bump so it ramps in softly, never spikes.
errorHeat = Math.min(0.85, errorHeat + 0.1 * dErr);
}
}
function setPage(pageId) {
const wasDashboard = onDashboard;
onDashboard = (pageId === 'dashboard') && isEnabled();
if (onDashboard && !wasDashboard) {
computeHomes();
resizeCanvas();
sparks = [];
enterOrbState();
} else if (!onDashboard && wasDashboard) {
if (collapseDelay) { clearTimeout(collapseDelay); collapseDelay = null; }
stopLoop();
state = 'idle';
sparks = [];
orbs.forEach(orb => {
orb.el.classList.remove('worker-orb-hidden', 'worker-orb-reveal');
});
if (canvas) {
canvas.style.display = 'none';
canvas.style.opacity = '0';
}
}
}
// ── Bootstrap ──
function bootstrap() {
init();
if (!dashboardHeader) return;
window.workerOrbs = { setPage, onStatus };
const activePage = document.querySelector('.page.active');
if (activePage && activePage.id === 'dashboard-page' && isEnabled()) {
setTimeout(() => {
computeHomes();
resizeCanvas();
enterOrbState();
onDashboard = true;
}, 300);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
setTimeout(bootstrap, 100);
}
})();