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