/** * 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 } | null} */ export function mountThinkingOrb(canvas, options = {}) { if (!canvas || typeof canvas.getContext !== 'function') return null; const 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; const { mode, speed, opts } = resolvePreset(state, size); const draw = MODE_DRAWS[mode]; const clock = speed * speedMultiplier; 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() {} }; } 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); } }; }