index.jsx
8.1 kB · jsx · 184 lines
1import { useEffect, useMemo, useRef, useState } from 'react';2import { ready, ink, blit, paint } from '../../lib/mrly.js';3import { mount, Page, Row, Slider, Btn, Stats, Stat, Note } from '../../lib/app.jsx';4import { Pixels, Sketch } from '../../lib/draw.jsx';5import { useQuery } from '../../lib/query.js';6import { useSeeds, roll, Ramp, Sources, readSource, seedSource, SOURCE_FIRST } from '../../lib/select.jsx';7import { board, line, axis, tag } from '../../lib/chart.js';89const m = await ready();10const STEPS = 512;11const SIZE = 512;12const NEEDLES = [['33', 33], ['45', 45], ['78', 78], ['899', 899], ['900', 900]];13const FIRST = { ...SOURCE_FIRST, rpm: 33 };1415const drawn = (seed) => ({ rpm: roll(seed, [[0, 1800]])[0] });1617const offscreen = () => {18 const canvas = document.createElement('canvas');19 canvas.width = SIZE;20 canvas.height = SIZE;21 return canvas;22};2324function App() {25 const s = useSeeds();26 const [pick, set] = useQuery({ ...seedSource(s, FIRST), ...(s.get() ? drawn(s.get()) : null) });27 const [look, setLook] = useState({ ramp: 'fire', levels: 64, invert: false });28 const [glow, setGlow] = useState(1);29 const [playing, setPlaying] = useState(true);30 const strobe = useRef(null);31 const table = useRef(null);32 const disc = useRef(null);33 const raw = useRef(null);34 const angle = useRef(0);35 const last = useRef(0);36 const fps = useRef(60);37 disc.current ??= offscreen();38 raw.current ??= document.createElement('canvas');3940 const key = Object.keys(SOURCE_FIRST).map((name) => pick[name]).join(':');4142 const made = useMemo(() => {43 try {44 const src = readSource(pick);45 const profile = m.profile(src.field, src.size, STEPS);46 return {47 view: {48 grid: src.grid, name: src.name, fills: src.fills, side: src.size, profile,49 raw: src.grid ? null : m.sheet(src.field, src.size, look.ramp, look.levels, look.invert),50 wheel: m.wheel(profile, SIZE, look.ramp, look.levels, look.invert),51 stats: JSON.parse(m.spin_stats(profile, src.size)),52 },53 };54 } catch (error) {55 return { error };56 }57 }, [key, look]);5859 const shown = useRef(null);60 if (made.view) shown.current = made.view;61 const view = shown.current;6263 useEffect(() => {64 if (!view) return;65 if (view.grid) paint(raw.current, view.grid, ink.blue, ink.deep);66 else blit(raw.current, view.raw);67 const dctx = disc.current.getContext('2d');68 dctx.imageSmoothingEnabled = false;69 dctx.drawImage(raw.current, 0, 0, SIZE, SIZE);70 const tctx = table.current.getContext('2d');71 tctx.globalAlpha = 1;72 tctx.fillStyle = ink.deep;73 tctx.fillRect(0, 0, SIZE, SIZE);74 }, [view]);7576 useEffect(() => {77 let id = 0;78 const frame = (now) => {79 id = requestAnimationFrame(frame);80 const dt = last.current ? Math.min(0.05, (now - last.current) / 1000) : 0;81 last.current = now;82 if (dt > 0) fps.current = fps.current * 0.95 + 0.05 / dt;83 if (playing) angle.current = (angle.current + pick.rpm * 6 * dt) % 360;84 if (strobe.current) strobe.current.textContent = `${fps.current.toFixed(0)} fps, ${m.frame_step(pick.rpm, fps.current).toFixed(1)}° per frame`;85 const tctx = table.current.getContext('2d');86 tctx.globalAlpha = 1 / glow;87 tctx.fillStyle = ink.deep;88 tctx.fillRect(0, 0, SIZE, SIZE);89 tctx.save();90 tctx.translate(SIZE / 2, SIZE / 2);91 tctx.rotate(angle.current * Math.PI / 180);92 const side = SIZE / Math.SQRT2;93 tctx.drawImage(disc.current, -side / 2, -side / 2, side, side);94 tctx.restore();95 };96 id = requestAnimationFrame(frame);97 return () => cancelAnimationFrame(id);98 }, [pick.rpm, glow, playing]);99100 const chart = (canvas) => {101 if (!shown.current) return;102 const { profile, stats } = shown.current;103 const b = board(canvas, 200);104 const peak = Math.max(stats.peak, 1e-9);105 const low = Math.min(0, ...profile);106 const end = profile.length - 1;107 line(b, Array.from(profile, (v, k) => [k / end, (v - low) / (peak - low)]), ink.blue, { fill: 0.25 });108 const mark = (r, color, label, dx) => {109 const at = b.x(r / stats.reach);110 b.ctx.strokeStyle = color;111 b.ctx.lineWidth = 1;112 b.ctx.setLineDash([3, 3]);113 b.ctx.beginPath();114 b.ctx.moveTo(at, b.roof - 4);115 b.ctx.lineTo(at, b.floor);116 b.ctx.stroke();117 b.ctx.setLineDash([]);118 tag(b, label, color, 'left', at + dx);119 };120 if (stats.disc > 0) mark(stats.disc, ink.pink, `dark disc ${stats.disc.toFixed(2)}`, 4);121 mark(stats.inner, ink.yellow, `edge ${stats.inner.toFixed(1)}`, -60);122 axis(b, [[0, '0'], [1, `radius in cells, corner ${stats.reach.toFixed(1)}`]]);123 tag(b, `circle mean, peak ${stats.peak.toFixed(3)}`, ink.blue);124 };125126 const controls = (127 <>128 <section>129 <h3>Turntable</h3>130 <Row>131 <Btn onClick={() => setPlaying(!playing)}>{playing ? 'Stop' : 'Spin'}</Btn>132 <Slider label="rpm" value={pick.rpm} min={0} max={1800} onChange={(v) => set({ rpm: v })} />133 <span className="tabs">134 {NEEDLES.map(([word, rpm]) => <Btn key={word} onClick={() => set({ rpm })}>{word}</Btn>)}135 </span>136 <Slider label="afterglow" value={glow} min={1} max={120} onChange={setGlow} />137 </Row>138 </section>139 <section>140 <h3>Source</h3>141 <Row>142 <Sources value={pick} onChange={set} seeds={s} onSeed={(seed) => set(drawn(seed))} />143 </Row>144 </section>145 <section>146 <h3>Colour</h3>147 <Row>148 <Ramp value={look} onChange={(patch) => setLook({ ...look, ...patch })} />149 </Row>150 </section>151 </>152 );153154 return (155 <Page crumb="spin" title="Spin the carpet like a record"156 sub="Put a design on a turntable and speed it up. The screen strobes at its own frame rate, so a fast carpet freezes, drifts backwards, or smears; the eye keeps an afterglow and blends the frames. Spun infinitely fast, every pixel becomes the mean of the picture on its own circle, and that picture is not guessed: the wheel on the right is the exact circle mean at every radius, a bullseye whose rings are the design."157 controls={controls}158 foot={<>The turntable is an ordinary rotation drawn once per screen frame; afterglow blends each new frame into the old ones. The wheel is computed in Rust: every circle about the centre is cut at the grid lines it crosses and each arc is read from the one cell it lies in, so the mean over the circle is exact, and the rings integrate back to the mass of the source. The theory behind the bullseye is one identity: a plane wave averaged over a turn is the Bessel function <code>J0(|k| r)</code>, so the wheel is the Hankel transform of the radially averaged spectrum. On the square lattice the ring frequencies are the sums of two squares; on the hexagonal lattice they are the Loeschian numbers. A 60 Hz screen freezes a carpet at 900 rpm, a quarter turn per frame. The identities behind the bullseye and the census run on them are in <a href="/research/spin/">the spin note</a>.</>}>159 <div className="arena">160 <div className="panel">161 <h2>The turntable <span ref={strobe} /></h2>162 <canvas ref={table} className="sheet" width={SIZE} height={SIZE} role="img" aria-label="The turntable" />163 </div>164 <div className="panel">165 <h2>Infinite speed <span>the exact circle means</span></h2>166 {view && <Pixels data={view.wheel} role="img" aria-label="Infinite speed, the exact circle means" />}167 </div>168 </div>169 <Sketch draw={chart} deps={[view]} className="bars" role="img" aria-label="Circle mean by radius" />170 <Stats>171 <Stat label="name">{view?.name}</Stat>172 <Stat label="side">{view?.side}</Stat>173 <Stat label="filled">{view?.fills}</Stat>174 <Stat label="mass of the rings">{view?.stats.mass.toFixed(1)}</Stat>175 <Stat label="dark disc to">{view?.stats.disc.toFixed(2)}</Stat>176 <Stat label="brightest ring">{view?.stats.peak.toFixed(3)}</Stat>177 <Stat label="last ring at">{view?.stats.reach.toFixed(1)}</Stat>178 </Stats>179 <Note error={made.error} />180 </Page>181 );182}183184mount(<App />);