New
Snap ToggleDraggable
A switch you pull. Eighteen masses on inextensible links, and the click fires when the plunger runs out of travel — not when the angle looks right.
'use client';
import './pull-cord.css';
import { useEffect, useState } from 'react';
import {
useCanvasScene,
useReducedMotion,
type SceneDrawContext,
type SceneSetupContext,
} from '@/hooks/use-canvas-scene';
/**
* A switch you pull, with a cord that is a real rope.
*
* The cord is eighteen point masses integrated by Verlet and relaxed against
* inextensible distance constraints — position-based dynamics, the same method a
* cloth solver uses. Nothing about the swing is authored: the arc when you drag it
* sideways is the pendulum the rope is, and the wave that runs down it after the
* switch trips is the top of the rope having moved while the bottom had not heard
* yet.
*
* The click is a detent, not a timer. A real pull-chain switch does not fire when
* the chain reaches an angle; it fires when the chain has drawn a plunger a fixed
* distance out of the mechanism against a spring, and then the plunger snaps home.
* So the rope here is genuinely inextensible and the travel is paid out by the
* mount: pull past the point where the rope is taut and the plunger follows your
* hand, until it reaches the end of its travel and trips. It cannot fire twice
* without being let up first, because the detent has to re-seat.
*
* The rope is the pointer affordance; the switch is a real `role="switch"` button
* that rides on the knob, so a keyboard reaches it and a screen reader is told
* what it is and whether it is on.
*/
/** Seconds per step. */
const STEP = 1 / 120;
/** Masses in the cord. */
const NODES = 18;
/** Rest length of one link, in pixels. Eighteen of these is the cord. */
const SEGMENT = 9;
/** Gravity, px/s². */
const G = 2200;
/** Velocity lost per step. Air, and the fibre's own hysteresis. */
const DRAG = 0.008;
/**
* Relaxation passes over the links per step. Position-based dynamics converges on
* an inextensible rope in single figures; eight leaves under a pixel of stretch
* across the whole cord even while the knob is being hauled on.
*/
const RELAX = 8;
/** The knob's inverse mass. Heavier than a link, so the cord whips and it does not. */
const KNOB_INV = 0.25;
/** Stiffness of the hand's hold on the knob, per relaxation pass. */
const GRAB = 0.55;
/** How near the knob a press has to land to take hold of it. */
const REACH = 30;
/** The plunger's travel before the switch trips, in pixels. */
const DETENT = 22;
/** How fast the plunger follows the pull. Stiff: this is steel, not elastic. */
const PLUNGER = 40;
/** The plunger has to come back inside this fraction of its travel to re-arm. */
const RESEAT = 0.4;
/** Where the mount hangs, below the top of the stage. */
const MOUNT_Y = 6;
interface CordState {
readonly count: number;
/** Positions, and the positions one step ago. Verlet keeps velocity in the gap. */
readonly x: Float64Array;
readonly y: Float64Array;
readonly px: Float64Array;
readonly py: Float64Array;
readonly inv: Float64Array;
readonly mountX: number;
/** How far the plunger has been drawn out of the mechanism, 0…DETENT. */
sag: number;
/** True while the detent is seated and able to trip. */
armed: boolean;
/** True while the hand has hold of the knob. */
held: boolean;
/** Set by `advance` when the detent trips; the component reads and clears it. */
fired: boolean;
/** Whether the lamp is on, for the knob's own bloom. Written by the component. */
lit: boolean;
/** Last cord position published to CSS, so the write is skipped when it has not moved. */
postedX: number;
postedY: number;
carry: number;
clock: number;
/** Hang the cord straight and take no drags. Set under `prefers-reduced-motion`. */
snap: boolean;
}
/** The pose the cord holds with nothing acting on it but gravity: a straight line. */
function hang(state: CordState) {
for (let i = 0; i < state.count; i++) {
state.x[i] = state.mountX;
state.y[i] = MOUNT_Y + i * SEGMENT;
state.px[i] = state.x[i];
state.py[i] = state.y[i];
}
state.sag = 0;
state.armed = true;
}
function advance(state: CordState, grabX: number, grabY: number) {
const { count, x, y, px, py, inv } = state;
/*
* The plunger. `sag` is not integrated from a tension estimate — it is read off
* the geometry, which is exact: the rope cannot stretch, so if the hand is D from
* the mount and the rope is L long, the mechanism has had to pay out D − L, and
* never more than its own travel.
*/
const demand = state.held
? Math.hypot(grabX - state.mountX, grabY - MOUNT_Y) - SEGMENT * (count - 1)
: 0;
const wanted = Math.max(0, Math.min(DETENT, demand));
state.sag += (wanted - state.sag) * Math.min(1, STEP * PLUNGER);
if (state.armed && state.sag >= DETENT - 0.5) {
// Trip. The plunger snaps home inside one step, which is what puts the wave in
// the cord: the top has moved twenty-two pixels and the bottom has not heard.
state.armed = false;
state.fired = true;
state.sag = 0;
} else if (!state.armed && wanted < DETENT * RESEAT) {
state.armed = true;
}
// Verlet, for every node but the first. Node 0 is the plunger's eye: it is
// placed, and its zero inverse mass keeps the relaxation from moving it.
for (let i = 1; i < count; i++) {
const vx = (x[i] - px[i]) * (1 - DRAG);
const vy = (y[i] - py[i]) * (1 - DRAG);
px[i] = x[i];
py[i] = y[i];
x[i] += vx;
y[i] += vy + G * STEP * STEP;
}
x[0] = state.mountX;
y[0] = MOUNT_Y + state.sag;
for (let pass = 0; pass < RELAX; pass++) {
for (let i = 1; i < count; i++) {
const dx = x[i] - x[i - 1];
const dy = y[i] - y[i - 1];
const distance = Math.hypot(dx, dy) || 1e-6;
const share = (distance - SEGMENT) / distance / (inv[i - 1] + inv[i]);
x[i - 1] += dx * share * inv[i - 1];
y[i - 1] += dy * share * inv[i - 1];
x[i] -= dx * share * inv[i];
y[i] -= dy * share * inv[i];
}
/*
* The hand, as one more constraint rather than as an assignment. Setting the
* knob's position outright would overwrite the Verlet history that *is* its
* velocity, and letting go mid-swing would drop it dead. As a constraint the
* motion stays in the positions, so a throw carries.
*/
if (state.held) {
x[count - 1] += (grabX - x[count - 1]) * GRAB;
y[count - 1] += (grabY - y[count - 1]) * GRAB;
}
}
}
function build({ width }: SceneSetupContext, lit: boolean): CordState {
const count = NODES;
const inv = new Float64Array(count);
for (let i = 1; i < count; i++) inv[i] = 1;
// The knob is the heavy end. Weighting the corrections by inverse mass is what
// makes the cord whip around it instead of the two trading places.
inv[count - 1] = KNOB_INV;
const state: CordState = {
count,
x: new Float64Array(count),
y: new Float64Array(count),
px: new Float64Array(count),
py: new Float64Array(count),
inv,
mountX: width * 0.78,
sag: 0,
armed: true,
held: false,
fired: false,
lit,
postedX: Number.NaN,
postedY: Number.NaN,
carry: 0,
clock: 0,
snap: false,
};
// Hanging straight is the exact rest pose, not an approximation of one: with only
// gravity acting and every link inextensible, the solution is a vertical line.
hang(state);
return state;
}
function paint({ context, width, height, state, pointer }: SceneDrawContext<CordState>) {
const now = performance.now();
const elapsed = state.clock ? Math.min(0.05, (now - state.clock) / 1000) : STEP;
state.clock = now;
const knob = state.count - 1;
if (state.snap) {
state.held = false;
hang(state);
} else {
// Take hold on a press that lands near the knob, and let go when it ends. The
// grab is not re-tested while held, so dragging outside the stage keeps it.
if (!pointer.down) state.held = false;
else if (
!state.held &&
pointer.inside &&
Math.hypot(pointer.x - state.x[knob], pointer.y - state.y[knob]) < REACH
) {
state.held = true;
}
state.carry += elapsed;
let steps = 0;
while (state.carry >= STEP && steps < 6) {
advance(state, pointer.x, pointer.y);
state.carry -= STEP;
steps += 1;
}
if (state.carry > STEP * 6) state.carry = 0;
}
context.clearRect(0, 0, width, height);
// The mount, and the plunger at its real extension. The travel is drawn because
// the travel is the mechanism: what you see move is what decides when it clicks.
context.fillStyle = 'rgba(228,214,190,0.14)';
context.fillRect(state.mountX - 17, 0, 34, MOUNT_Y);
context.fillStyle = 'rgba(238,222,196,0.46)';
context.fillRect(state.mountX - 1.5, MOUNT_Y, 3, Math.max(0, state.sag));
/*
* The cord through the midpoints of its links rather than through the nodes: a
* quadratic to each midpoint with the node as the control point is C¹ across the
* whole rope, so at nine pixels a link there is no facet to see. Drawing node to
* node is a polygon, and it reads as one the moment the cord swings.
*/
const { count, x, y } = state;
context.strokeStyle = 'rgba(230,209,173,0.7)';
context.lineWidth = 2.2;
context.lineJoin = 'round';
context.lineCap = 'round';
context.beginPath();
context.moveTo(x[0], y[0]);
for (let i = 1; i < count - 1; i++) {
context.quadraticCurveTo(x[i], y[i], (x[i] + x[i + 1]) * 0.5, (y[i] + y[i + 1]) * 0.5);
}
context.lineTo(x[knob], y[knob]);
context.stroke();
const kx = x[knob];
const ky = y[knob];
if (state.lit) {
context.shadowColor = 'rgba(255,203,132,0.6)';
context.shadowBlur = 26;
}
const bead = context.createRadialGradient(kx - 3, ky - 4, 1, kx, ky, 12);
bead.addColorStop(0, state.lit ? '#fff4d8' : '#e6dac2');
bead.addColorStop(1, state.lit ? '#b8863f' : '#877553');
context.fillStyle = bead;
context.beginPath();
context.arc(kx, ky, 10, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
/*
* The knob's position, published to CSS so the switch button rides on it and the
* focus ring lands where the control actually is. Written only when it has moved
* a visible amount: a custom property on the stage invalidates style for its
* subtree, and there is no reason to pay that on a frame where nothing moved.
*/
if (Math.abs(kx - state.postedX) > 0.5 || Math.abs(ky - state.postedY) > 0.5) {
state.postedX = kx;
state.postedY = ky;
const host = context.canvas.parentElement;
if (host) {
host.style.setProperty('--cord-x', `${kx.toFixed(1)}px`);
host.style.setProperty('--cord-y', `${ky.toFixed(1)}px`);
}
}
}
export type PullCordProps = {
/** Card variant: one line of type along the bottom, the cord given the whole box. */
compact?: boolean;
};
/**
* The cord layer is the stage and takes every pointer event; the copy sits on it
* with `pointer-events: none`. The switch is a real button, focusable and labelled,
* but `pointer-events: none` as well — so a mouse press on the knob is the drag the
* cord expects, while Tab and Space reach the same control and toggle it outright.
*/
export function PullCord({ compact = false }: PullCordProps) {
const [on, setOn] = useState(false);
const reduced = useReducedMotion();
const { stageRef, canvasRef, requestRender } = useCanvasScene<CordState>({
setup: (scene) => build(scene, on),
draw: (scene) => {
scene.state.lit = on;
scene.state.snap = reduced;
paint(scene);
// The detent trips inside the solver, so the React state follows the physics
// rather than the other way round.
if (scene.state.fired) {
scene.state.fired = false;
setOn((was) => !was);
}
},
});
// With the loop stopped under reduced motion, the toggle still has to repaint or
// the knob would keep the colour of the state it just left.
useEffect(() => {
requestRender();
}, [on, requestRender]);
return (
<div
className="pull-cord-stage"
data-on={on ? 'true' : 'false'}
data-compact={compact ? 'true' : undefined}
>
<div ref={stageRef} className="pull-cord-line">
<canvas ref={canvasRef} aria-hidden="true" />
<button
type="button"
role="switch"
className="pull-cord-switch"
aria-checked={on}
aria-label="Reading light"
// The card frame is aria-hidden, so inside it the switch leaves the tab
// order. It stays clickable — only the keyboard path is withdrawn.
tabIndex={compact ? -1 : undefined}
onClick={() => setOn((was) => !was)}
/>
</div>
<div className="pull-cord-face">
<p className="pull-cord-eyebrow">Detent</p>
<h2>{on ? 'The light is on.' : 'The light is off.'}</h2>
<p className="pull-cord-copy">
Eighteen masses, inextensible links, and a plunger with twenty-two pixels of
travel. It clicks when the mechanism reaches the end of that travel, and it
will not click again until the cord has been let up.
</p>
</div>
<p className="pull-cord-hint">Pull the cord</p>
</div>
);
}
export default PullCord;.pull-cord-stage {
position: relative;
width: 100%;
min-height: 340px;
overflow: hidden;
border-radius: 0.75rem;
background: radial-gradient(120% 100% at 78% 4%, #14171d 0%, #0a0c11 58%, #06070a 100%);
color: #f2ece0;
isolation: isolate;
}
/* The lamp, as light rather than as a lamp. Behind the cord because a pseudo
element paints before its element's children. */
.pull-cord-stage::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(58% 62% at 78% 2%, rgba(255, 194, 118, 0.32), rgba(255, 194, 118, 0) 72%);
opacity: 0;
pointer-events: none;
transition: opacity 280ms ease;
}
.pull-cord-stage[data-on='true']::before {
opacity: 1;
}
.pull-cord-line {
position: absolute;
inset: 0;
cursor: grab;
touch-action: none;
}
.pull-cord-line:active {
cursor: grabbing;
}
.pull-cord-line canvas {
display: block;
width: 100%;
height: 100%;
}
/*
* Rides on the knob: the draw loop publishes the knob's position as `--cord-x` and
* `--cord-y`, so the focus ring lands on the control instead of on a guess about
* where the control usually hangs. Deaf to the pointer, because a press on the knob
* belongs to the cord — but still in the tab order, and still activated by Space.
*/
.pull-cord-switch {
position: absolute;
top: 0;
left: 0;
width: 2.5rem;
height: 2.5rem;
appearance: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
transform: translate(calc(var(--cord-x, 0px) - 1.25rem), calc(var(--cord-y, 0px) - 1.25rem));
pointer-events: none;
}
.pull-cord-switch:focus-visible {
outline: 2px solid rgba(255, 214, 150, 0.85);
outline-offset: 3px;
}
/* Transparent to the pointer, so the cord can be grabbed through the copy. */
.pull-cord-face {
position: relative;
max-width: 27rem;
padding: 4.5rem 3rem 5.25rem;
pointer-events: none;
}
.pull-cord-eyebrow {
margin: 0 0 1rem;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 196, 122, 0.78);
}
.pull-cord-face h2 {
margin: 0 0 1rem;
font-size: clamp(1.625rem, 3.2vw, 2.5rem);
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.02em;
text-wrap: balance;
color: rgba(242, 236, 224, 0.72);
transition: color 280ms ease;
}
.pull-cord-stage[data-on='true'] .pull-cord-face h2 {
color: #fff6e6;
}
.pull-cord-copy {
margin: 0;
max-width: 24rem;
font-size: 0.9375rem;
line-height: 1.65;
color: rgba(242, 236, 224, 0.5);
transition: color 280ms ease;
}
.pull-cord-stage[data-on='true'] .pull-cord-copy {
color: rgba(242, 236, 224, 0.66);
}
.pull-cord-hint {
position: absolute;
right: 0.875rem;
bottom: 0.75rem;
margin: 0;
font: 500 0.6875rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(242, 236, 224, 0.28);
pointer-events: none;
}
/*
* With the loop stopped the cord hangs straight and refuses to be dragged, because
* dragging it would be motion smuggled in through the pointer handler. The switch
* button is the whole control here, and it is a real one — Tab to it, Space toggles
* it, and the lamp changes state with no travel in between.
*/
@media (prefers-reduced-motion: reduce) {
.pull-cord-line {
cursor: default;
}
.pull-cord-stage::before,
.pull-cord-face h2,
.pull-cord-copy {
transition: none;
}
}
/*
* The card variant: the same cord authored for the 298x240 catalogue frame rather
* than scaled into it. The section copy goes, one line of state stays pinned along
* the bottom edge, and the mechanism — mount, plunger, rope, knob — is given the
* whole box. No `vw` anywhere below: the frame is 298px wide and the viewport is not.
*/
.pull-cord-stage[data-compact='true'] {
min-height: 0;
height: 100%;
/* The card frame rounds and clips already. */
border-radius: 0;
}
/*
* `pan-y`, not `none`, even though pulling a cord is a vertical gesture: a full-bleed
* drag surface that swallows vertical touches traps the page in a scrolling grid of
* cards, which is the worse failure. Horizontal drags still reach the cord, and about
* 85px of sideways swing draws the plunger its full 22px and trips the detent — so
* the real behaviour, click included, survives the trade on a phone.
*/
.pull-cord-stage[data-compact='true'] .pull-cord-line {
touch-action: pan-y;
}
/*
* The text layer off the mechanism's back and onto the bottom edge. Still deaf to the
* pointer, so a drag anywhere over it takes hold of the cord.
*/
.pull-cord-stage[data-compact='true'] .pull-cord-face {
position: absolute;
inset: auto 0 0 0;
max-width: none;
padding: 0.75rem;
pointer-events: none;
}
/* Everything but the state line: a paragraph of prose, a label the card already
carries in its own title, and a hint the grab cursor gives for free. */
.pull-cord-stage[data-compact='true'] .pull-cord-copy,
.pull-cord-stage[data-compact='true'] .pull-cord-eyebrow,
.pull-cord-stage[data-compact='true'] .pull-cord-hint {
display: none;
}
/*
* The one line that stays, at a fixed size. It is the heading because the heading is
* the state readout — it is what turns over when the detent trips, so the card says
* whether the pull worked. The rope is 153px of the 240 and the knob cannot swing
* past about 191, so this strip is clear of it.
*/
.pull-cord-stage[data-compact='true'] .pull-cord-face h2 {
margin: 0;
font-size: 0.8125rem;
line-height: 1.2;
white-space: nowrap;
}"use client"
import { useCallback, useEffect, useRef, useState } from "react"
/**
* The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing
* store, a rebuild on resize, a loop that stops when the stage scrolls out of
* view, pointer tracking with per-frame deltas, and teardown.
*
* A scene supplies two functions. `setup` builds whatever mutable state the
* animation owns and is re-run whenever the stage changes size, so the state can
* be sized to the stage without ever being resized in place. `draw` paints one
* frame from that state — it is called with the transform already scaled to
* device pixels, so every coordinate in it is a CSS pixel.
*/
export type ScenePointer = {
x: number
y: number
/** Position at the previous painted frame, so `x - lastX` is a frame delta. */
lastX: number
lastY: number
down: boolean
inside: boolean
}
export type SceneSetupContext = {
context: CanvasRenderingContext2D
width: number
height: number
dpr: number
}
export type SceneDrawContext<State> = SceneSetupContext & {
state: State
pointer: ScenePointer
/** Painted frames since the last rebuild. Useful for every-Nth-frame work. */
frame: number
}
export type CanvasSceneOptions<State> = {
setup: (context: SceneSetupContext) => State
draw: (context: SceneDrawContext<State>) => void
}
export type CanvasScene = {
/** The sizing element. Owns the pointer listeners and is what is observed. */
stageRef: (node: HTMLDivElement | null) => void
canvasRef: (node: HTMLCanvasElement | null) => void
/** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */
requestRender: () => void
}
/** Live `prefers-reduced-motion`. False during SSR and the first paint. */
export function useReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)")
setReduced(query.matches)
const onChange = () => setReduced(query.matches)
query.addEventListener("change", onChange)
return () => query.removeEventListener("change", onChange)
}, [])
return reduced
}
export function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {
const reduced = useReducedMotion()
/*
* `draw` is usually an inline closure, so it is a new function on every
* render. Reading it through a ref keeps the loop from being torn down and
* the scene from being rebuilt each time the component re-renders.
*/
const optionsRef = useRef(options)
optionsRef.current = options
const stage = useRef<HTMLDivElement | null>(null)
const canvas = useRef<HTMLCanvasElement | null>(null)
/*
* Plain ref assignment, with no state behind it. React attaches refs during
* the commit phase, before passive effects run, so the effect below already
* sees both nodes on the first mount — which is why these used to bump a
* `mounted` counter for nothing: the two `setMounted` calls batched into one
* re-render, the counter went 0 → 2, and the effect's dependency on it tore
* the live scene down and rebuilt it. Every scene was constructed, measured
* and warmed twice on every mount, four times under StrictMode in dev.
*
* The requirement this trades for that: a consumer must render the stage and
* the canvas unconditionally, in the same commit as the component itself. All
* thirteen do. Gating the canvas behind a flag would leave the effect bailing
* on the null guard with nothing to re-run it.
*/
const stageRef = useCallback((node: HTMLDivElement | null) => {
stage.current = node
}, [])
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
canvas.current = node
}, [])
/** Set once the scene is live, so `requestRender` before that is a no-op. */
const render = useRef<(() => void) | null>(null)
const requestRender = useCallback(() => render.current?.(), [])
useEffect(() => {
const stageNode = stage.current
const canvasNode = canvas.current
if (!stageNode || !canvasNode) return
const context = canvasNode.getContext("2d")
if (!context) return
const pointer: ScenePointer = {
x: 0,
y: 0,
lastX: 0,
lastY: 0,
down: false,
inside: false,
}
let state: State | null = null
let width = 0
let height = 0
let dpr = 1
let frame = 0
let loop = 0
let pending = 0
let visible = true
/** Rebuild the backing store and the scene state for the current size. */
const measure = () => {
// `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is
// post-transform, so a scene sitting inside a scaled ancestor measured its
// own frame at the scaled size, sized the backing store to that, and then
// had CSS scale the result a second time — the scene ran at a fraction of
// the box it was drawn into. The catalogue's scaled-poster branch is the
// one place that happens, and it is reachable again the moment an
// animation is registered without a card composition. These two properties
// are the untransformed layout box; both are integers, which is what the
// rounding below already reduced the rect to.
const nextWidth = Math.max(1, stageNode.offsetWidth)
const nextHeight = Math.max(1, stageNode.offsetHeight)
const nextDpr = Math.min(2, window.devicePixelRatio || 1)
if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return
width = nextWidth
height = nextHeight
dpr = nextDpr
canvasNode.width = Math.round(width * dpr)
canvasNode.height = Math.round(height * dpr)
canvasNode.style.width = `${width}px`
canvasNode.style.height = `${height}px`
frame = 0
state = optionsRef.current.setup({ context, width, height, dpr })
}
const paint = () => {
if (!state) return
// Re-applied every frame: a scene is free to install its own transform
// for a cell or a sprite, and most do.
context.setTransform(dpr, 0, 0, dpr, 0, 0)
optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })
pointer.lastX = pointer.x
pointer.lastY = pointer.y
frame += 1
}
/** One frame on the next tick, coalescing however many were asked for. */
const paintOnce = () => {
if (pending) return
pending = requestAnimationFrame(() => {
pending = 0
measure()
paint()
})
}
render.current = paintOnce
const tick = () => {
loop = requestAnimationFrame(tick)
if (visible) paint()
}
const start = () => {
if (loop || reduced) return
loop = requestAnimationFrame(tick)
}
const stop = () => {
if (!loop) return
cancelAnimationFrame(loop)
loop = 0
}
const at = (event: PointerEvent) => {
const rect = stageNode.getBoundingClientRect()
// The rect is the right thing to subtract here — `clientX` is viewport
// space and so is the rect — but the difference comes back in *rendered*
// pixels, and a scene reads `pointer` in the scene pixels `measure()` set
// up from the untransformed box. Under a CSS scale those two disagree, so
// divide the transform back out. `rect.width / offsetWidth` is the scale
// actually in force, whatever produced it, and it is exactly 1 when there
// is none.
const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1
pointer.x = (event.clientX - rect.left) / (scale || 1)
pointer.y = (event.clientY - rect.top) / (scale || 1)
// A frozen loop still owes the user feedback for a drag.
if (reduced) paintOnce()
}
const onEnter = (event: PointerEvent) => {
pointer.inside = true
at(event)
pointer.lastX = pointer.x
pointer.lastY = pointer.y
}
const onMove = (event: PointerEvent) => {
pointer.inside = true
at(event)
}
const onDown = (event: PointerEvent) => {
pointer.down = true
at(event)
// Capture keeps a drag alive past the edge of the stage, which is where
// a hard throw naturally ends up.
stageNode.setPointerCapture(event.pointerId)
}
const onUp = (event: PointerEvent) => {
pointer.down = false
at(event)
if (stageNode.hasPointerCapture(event.pointerId)) {
stageNode.releasePointerCapture(event.pointerId)
}
}
const onLeave = () => {
pointer.inside = false
pointer.down = false
if (reduced) paintOnce()
}
stageNode.addEventListener("pointerenter", onEnter)
stageNode.addEventListener("pointermove", onMove)
stageNode.addEventListener("pointerdown", onDown)
stageNode.addEventListener("pointerup", onUp)
stageNode.addEventListener("pointercancel", onUp)
stageNode.addEventListener("pointerleave", onLeave)
const resizes = new ResizeObserver(() => paintOnce())
resizes.observe(stageNode)
/*
* An animation nobody can see is heat. The observer both pauses the loop
* and, on the way back in, repaints immediately rather than waiting a frame.
*/
const views = new IntersectionObserver(
(entries) => {
visible = entries.some((entry) => entry.isIntersecting)
if (visible) {
start()
paintOnce()
} else {
stop()
}
},
{ rootMargin: "120px" },
)
views.observe(stageNode)
measure()
paint()
start()
return () => {
render.current = null
stop()
if (pending) cancelAnimationFrame(pending)
resizes.disconnect()
views.disconnect()
stageNode.removeEventListener("pointerenter", onEnter)
stageNode.removeEventListener("pointermove", onMove)
stageNode.removeEventListener("pointerdown", onDown)
stageNode.removeEventListener("pointerup", onUp)
stageNode.removeEventListener("pointercancel", onUp)
stageNode.removeEventListener("pointerleave", onLeave)
}
}, [reduced])
return { stageRef, canvasRef, requestRender }
}