Submit joins the admission queue first and sends the PAT only once the reservation is held. Queued state shows the shaping orb, ordinal position with estimate, and an exit button whose label changes with the wait; running switches the orb to solving. Rejoin once on a lost reservation or ticket, best-effort leave on pagehide. Mock server gains the queue endpoints and five scenarios; contract, unit and e2e tests cover them.
132 lines
4.2 KiB
JavaScript
132 lines
4.2 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, 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);
|
|
}
|
|
};
|
|
}
|