// ============================================================ // 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 ripples = []; // impact rings where a pulse lands on the nucleus const MAX_RIPPLES = 24; // ── Sleep/wake ── // With zero workers active (and no pulses in flight) for SLEEP_AFTER_MS, // the system drowses: nucleus dims to embers, spokes fade, drift slows to // a crawl. First sign of work snaps it awake (~0.3s) with a ripple bloom // from the nucleus. The idle/busy contrast is what makes activity read. const SLEEP_AFTER_MS = 75000; let sleepLevel = 0; // 0 awake .. 1 asleep (eased, never snaps) let lastActivityAt = performance.now(); let wakeBurstDone = true; 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, // Pseudo-3D orbital depth: z oscillates -1 (behind the nucleus, // smaller + dimmer) to +1 (in front, larger + brighter). Pure // draw-time effect — the force physics stay 2D. z: 0, zPhase: Math.random() * Math.PI * 2, zSpeed: 0.22 + Math.random() * 0.18, kick: 0, // excitement-kick energy (decays) 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(); centerOrbs(); 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 centerOrbs() { // Spawn the whole cluster dead-center and let the physics bloom it // outward. Positions EVERY orb (visible or not): the old random // scatter skipped not-yet-visible orbs, so on page load they all sat // at the canvas origin and drifted in from the top-left corner. if (!canvas) return; const w = canvas.clientWidth || 600; const h = canvas.clientHeight || 80; orbs.forEach(orb => { // A few px of jitter so the separation force can split the stack // (it ignores pairs closer than 0.1px). orb.x = w / 2 + (Math.random() - 0.5) * 6; orb.y = h / 2 + (Math.random() - 0.5) * 6; orb.vx = (Math.random() - 0.5) * 0.6; orb.vy = (Math.random() - 0.5) * 0.6; }); } 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(hub) { for (let i = inflows.length - 1; i >= 0; i--) { const p = inflows[i]; p.t += p.speed; if (p.t >= 1) { // The pulse lands: ripple + a couple of debris sparks at the // contact point on the nucleus rim, so the hub visibly absorbs // it instead of the dot just vanishing. if (hub) { const dx = hub.x - p.orb.x, dy = hub.y - p.orb.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / d, uy = dy / d; const rim = ORB_RADIUS + 4; emitImpact(hub.x - ux * rim, hub.y - uy * rim, p.color, ux, uy); } inflows.splice(i, 1); } } } // ── Impact ripples (pulse → nucleus contact) ── function emitImpact(x, y, color, dirX, dirY) { if (ripples.length < MAX_RIPPLES) { ripples.push({ x, y, color, age: 0, life: 26 + Math.random() * 8 }); } // Two debris sparks splash back roughly the way the pulse came, fanned. for (let i = 0; i < 2 && sparks.length < MAX_SPARKS; i++) { const spread = (Math.random() - 0.5) * 1.6; const cos = Math.cos(spread), sin = Math.sin(spread); const rx = -(dirX * cos - dirY * sin); const ry = -(dirX * sin + dirY * cos); const speed = 0.5 + Math.random() * 0.7; sparks.push({ x, y, vx: rx * speed, vy: ry * speed, life: 0.8, decay: 0.03 + Math.random() * 0.02, color, radius: 1 + Math.random() * 1.2, }); } } function updateRipples() { for (let i = ripples.length - 1; i >= 0; i--) { if (++ripples[i].age >= ripples[i].life) ripples.splice(i, 1); } } function drawRipples(ctx) { for (const rp of ripples) { if (rp.age < 0) continue; // staggered (wake bloom) const t = rp.age / rp.life; // 0 → 1 const [r, g, b] = rp.color; ctx.beginPath(); ctx.arc(rp.x, rp.y, 3 + t * 11, 0, Math.PI * 2); ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${(1 - t) * 0.45})`; ctx.lineWidth = 1.5 * (1 - t) + 0.5; ctx.stroke(); } } 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; // Comet tail — ghost positions at trailing t values. The path is // parametric, so no position history is needed; the same easing // evaluated slightly in the past gives a tail that stretches as // the pulse accelerates into the hub. for (let i = 3; i >= 1; i--) { const tt = p.t - i * p.speed * 2.4; if (tt <= 0) continue; const te = tt * tt; const tx = p.orb.x + (hub.x - p.orb.x) * te; const ty = p.orb.y + (hub.y - p.orb.y) * te; ctx.beginPath(); ctx.arc(tx, ty, Math.max(0.4, radius * (1 - i * 0.24)), 0, Math.PI * 2); ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha * (1 - i / 4) * 0.5})`; 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); 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(); // Dashboard (re)activation: the canvas just got its real size — init may // have run while the header was hidden (0x0), leaving positions stale. // Bloom the cluster from dead center instead of wherever that left them. centerOrbs(); 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 ripples = []; 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++; // Fully asleep: render at ~20fps. The drift is at crawl speed so the // difference is invisible, and the canvas GPU cost drops by two thirds // for the hours the dashboard sits idle. Wakes re-run every frame. if (sleepLevel > 0.95 && frameCount % 3 !== 0) return; 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; const nowActive = orb.btn ? orb.btn.classList.contains('active') : false; if (nowActive && !orb.active && !orb.hub) { // Excitement kick: a freshly-woken worker jolts into motion // (brief overspeed + size wobble + a few sparks) instead of // just getting brighter. Decays in physics/draw. const ang = Math.random() * Math.PI * 2; orb.vx += Math.cos(ang) * 1.6; orb.vy += Math.sin(ang) * 1.6; orb.kick = 1.0; for (let i = 0; i < 3; i++) { emitSpark(orb, orb.rainbow ? getRainbowColor(time) : null); } } orb.active = nowActive; }); } const visibleOrbs = orbs.filter(o => o.visible); const hub = visibleOrbs.find(o => o.hub); // Sleep/wake bookkeeping (see declarations for the design). if (visibleOrbs.some(o => !o.hub && o.active) || inflows.length) { lastActivityAt = performance.now(); } if (performance.now() - lastActivityAt > SLEEP_AFTER_MS) { sleepLevel = Math.min(1, sleepLevel + 0.004); // ~4s drowse-in wakeBurstDone = false; } else { if (!wakeBurstDone && sleepLevel > 0.4 && hub) { // Wake bloom: three staggered rings out of the nucleus. for (let i = 0; i < 3 && ripples.length < MAX_RIPPLES; i++) { ripples.push({ x: hub.x, y: hub.y, color: hub.rainbow ? getRainbowColor(time) : hub.color, age: -i * 6, life: 30, }); } } wakeBurstDone = true; sleepLevel = Math.max(0, sleepLevel - 0.05); // fast wake (~0.3s) } 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(hub); updateRipples(); // Draw ctx.clearRect(0, 0, w, h); drawConnections(ctx, visibleOrbs, time); drawSparks(ctx); drawInflows(ctx, hub); drawRipples(ctx); 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; an excitement kick // briefly lifts the cap so the jolt actually darts, then decays. orb.kick = (orb.kick || 0) * 0.94; const maxSpeed = (orb.active ? 0.8 : 0.5) * (1 + orb.kick * 2); 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 — asleep, the drift slows to a crawl (velocities keep // integrating so motion stays continuous through wake) const drowse = 1 - sleepLevel * 0.75; orb.x += orb.vx * drowse; orb.y += orb.vy * drowse; // 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) { // ── Depth pass ── // Each worker orb drifts on a slow z-oscillation; the hub sits at z=0. // Painter's order (back → front) makes orbs visibly pass BEHIND the // nucleus and swing back in front — the flat drift reads as an atom. // The effect eases out during the hover-expand morph so orbs land on // their buttons at natural size. const depthFade = state === 'expanding' ? (1 - expandProgress) : 1; for (const orb of visible) { orb.z = orb.hub ? 0 : Math.sin(time * orb.zSpeed + orb.zPhase) * depthFade; } const ordered = [...visible].sort((a, b) => a.z - b.z); for (const orb of ordered) { const [r, g, b] = orb.rainbow ? getRainbowColor(time) : orb.color; // -1 (back): ~18% smaller, dimmer. +1 (front): ~18% larger, full. // Sleep folds in as a further global dim (embers, not blackout). const dScale = 1 + orb.z * 0.18; const dAlpha = (0.78 + 0.22 * ((orb.z + 1) / 2)) * (1 - sleepLevel * 0.45); // ── 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. // Asleep, the nucleus dims to embers. const ember = 1 - sleepLevel * 0.55; 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) * ember); 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) * (1 - sleepLevel * 0.35); 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); } // Excitement-kick wobble: fast, shallow, gone in under a second if (orb.kick > 0.02) { baseRadius += orb.kick * 2.5 * Math.sin(time * 14 + orb.phase); } // Scale up during expand transition; depth scales the whole orb const currentRadius = (state === 'expanding' ? baseRadius + expandProgress * 4 : baseRadius) * dScale; // 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) * dAlpha; 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) * dAlpha; 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 (fades while asleep) 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)) * (1 - sleepLevel * 0.7); 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 * (1 - sleepLevel * 0.7); 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 = []; ripples = []; enterOrbState(); } else if (!onDashboard && wasDashboard) { if (collapseDelay) { clearTimeout(collapseDelay); collapseDelay = null; } stopLoop(); state = 'idle'; sparks = []; ripples = []; 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); } })();