/** * Vanilla driver for the thinking-orbs canvas engine. * * The upstream package ships a React component; this module reproduces its * behaviour on top of the framework-free engine build vendored under * `vendor/thinking-orbs.engine.js` (same-origin, CSP compliant): * device-pixel-ratio capped at 2, static frame under reduced motion, and * automatic pausing while off-screen or in a hidden tab. */ import { resolvePreset, MODE_DRAWS } from '../vendor/thinking-orbs.engine.js'; const REDUCED_MOTION_FRAME = 0.6; /** * Mounts an animated orb on an existing . * @param {HTMLCanvasElement} canvas * @param {{ state?: string, size?: 64|20, theme?: 'light'|'dark', speed?: number }} [options] * @returns {{ destroy: () => void, setState: (state: string) => void } | null} */ export function mountThinkingOrb(canvas, options = {}) { if (!canvas || typeof canvas.getContext !== 'function') return null; let state = options.state || 'working'; const size = options.size === 20 ? 20 : 64; const dark = options.theme === 'dark'; const speedMultiplier = typeof options.speed === 'number' && options.speed > 0 ? options.speed : 1; const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1); canvas.width = Math.round(size * dpr); canvas.height = Math.round(size * dpr); canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const ctx = canvas.getContext('2d'); if (!ctx) return null; let { mode, speed, opts } = resolvePreset(state, size); let draw = MODE_DRAWS[mode]; let clock = speed * speedMultiplier; /** * Re-resolves the preset for a new state in place, without tearing down the * IntersectionObserver / visibilitychange wiring. A no-op if the state is unchanged. * @param {string} nextState */ const applyState = (nextState) => { if (!nextState || nextState === state) return; state = nextState; ({ mode, speed, opts } = resolvePreset(state, size)); draw = MODE_DRAWS[mode]; clock = speed * speedMultiplier; }; // Mirrors the current preset name on the canvas as a plain data attribute (no inline style/ // script involved) so tests can assert the orb's sub-state without reaching into this closure. canvas.dataset.orbState = state; const setStateAttr = () => { canvas.dataset.orbState = state; }; const paintAt = (t) => { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size, size); draw(ctx, size, t, dark, opts); }; const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; if (reducedMotion) { paintAt(REDUCED_MOTION_FRAME); return { destroy() {}, setState(nextState) { applyState(nextState); setStateAttr(); paintAt(REDUCED_MOTION_FRAME); } }; } let frameId = 0; let running = false; let visible = true; const tick = () => { paintAt((performance.now() / 1000) * clock); if (running) frameId = requestAnimationFrame(tick); }; const start = () => { if (running) return; running = true; frameId = requestAnimationFrame(tick); }; const stop = () => { running = false; cancelAnimationFrame(frameId); }; paintAt((performance.now() / 1000) * clock); const observer = typeof IntersectionObserver === 'function' ? new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible && document.visibilityState !== 'hidden') start(); else stop(); }) : null; if (observer) observer.observe(canvas); const onVisibilityChange = () => { if (document.visibilityState === 'hidden') stop(); else if (visible) start(); }; document.addEventListener('visibilitychange', onVisibilityChange); if (!observer) start(); return { destroy() { stop(); if (observer) observer.disconnect(); document.removeEventListener('visibilitychange', onVisibilityChange); }, /** * Switches the orb to a different preset (e.g. `shaping` while queued, `solving` while * running) without re-creating the visibility/intersection observers. * @param {string} nextState */ setState(nextState) { applyState(nextState); setStateAttr(); paintAt((performance.now() / 1000) * clock); } }; }