Replace the gear spinner with the thinking-orbs canvas engine (vendored framework-free build, driven by js/orb.js in the "solving" state) and the inline SVG brand icon with a pure CSS book whose cover flips open on page load and while the brand is hovered (js/logo.js).
98 lines
3.0 KiB
JavaScript
98 lines
3.0 KiB
JavaScript
/**
|
|
* 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 <canvas>.
|
|
* @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);
|
|
}
|
|
};
|
|
}
|