Artur Mukhamadiev 3ef50c4448 frontend: thinking-orbs loading indicator and CSS book logo
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).
2026-09-15 12:00:26 +03:00

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);
}
};
}