index.jsx

14.9 kB · jsx · 277 lines

1import { useEffect, useMemo, useRef, useState } from 'react';2import { ready, ink, rgb, fit } from '../../lib/mrly.js';3import { mount, Page, Row, Pick, Slider, Btn, Check, Stats, Stat, Note } from '../../lib/app.jsx';4import { Sketch } from '../../lib/draw.jsx';5import { useQuery, stamp } from '../../lib/query.js';6import { useSeeds, seeded, Picker } from '../../lib/select.jsx';78const m = await ready();910const CAPS = JSON.parse(m.spirograph_caps());11const PENS = [['fill', 'the filled cells'], ['void', 'the empty cells'], ['both', 'both'], ['corners', 'the corners of the fills']];12const TRACKS = [['in', 'inside a circle'], ['out', 'outside a circle'], ['line', 'a straight line'], ['polyin', 'inside a polygon'], ['polyout', 'outside a polygon']];13const INKS = [['kind', 'fills blue, voids orange'], ['wheel', 'the six inks'], ['one', 'one ink']];14const SIDES = [3, 4, 5, 6, 8];15const LIVE = 300000;16const DENSITY = 720;17const TURN = 0.5;18const BEAT = 1000;19const PAD = 16;20const RASTER = [512, 256];21const CURVES = 64;22const SHADE = 110;2324const place = (value, places) => (Number.isFinite(value) ? value.toFixed(places) : '-');2526function App() {27  const s = useSeeds();28  const [q, setQ] = useQuery({29    code: seeded(s, 2, 3, '495'), base: 3, number: 3, level: 1, pens: 'fill', track: 'in',30    ring: 7, wheel: 3, sides: 4, laps: 2, reach: 90, jitter: 0, seed: 1, ink: 'kind', at: 1, fill: false,31  });32  const [at, setAt] = useState(q.at);33  const [playing, setPlaying] = useState(false);34  const live = useRef(at);35  const held = useRef(null);36  live.current = at;3738  const cap = m.level_cap(q.number, 2, CAPS.pencils);39  const level = Math.min(q.level, cap);40  const code = q.code.trim();4142  let error = null;43  try {44    const clock = performance.now();45    const grid = m.two_grid(code, q.number, level, 0, q.base);46    const args = [grid.types, grid.width, grid.height, q.pens, q.track, q.ring, q.wheel, q.sides, q.laps, q.reach / 100, q.jitter / 100, q.seed];47    const read = JSON.parse(m.spirograph_read(...args));48    const samples = Math.max(2, Math.min(Math.floor(CAPS.points / read.pencils), Math.floor(LIVE / read.pencils), DENSITY * read.orbits + 1));49    const curves = m.spirograph(...args, samples);50    held.current = { grid, read, curves, samples, name: m.name_of(code, 2, q.base), ms: performance.now() - clock };51  } catch (fault) {52    error = fault;53  }5455  const view = held.current;56  const read = view?.read;57  const round = q.track === 'in' || q.track === 'out';58  const cover = useMemo(() => {59    if (!q.fill || !view || error) return null;60    if (!round) return { fault: 'the fill needs a circle track: the wall of a line or a polygon roulette need not close' };61    const side = RASTER[read.distinct > CURVES ? 1 : 0];62    try {63      const raw = m.spirograph_cover(view.grid.types, view.grid.width, view.grid.height, q.pens, q.track, q.ring, q.wheel, q.sides, q.laps, q.reach / 100, q.jitter / 100, q.seed, side);64      return { mask: raw.mask, side: raw.side, covered: raw.covered, hole: raw.hole, wall: raw.wall, winding: raw.winding, areas: raw.areas, disc: raw.disc };65    } catch (fault) {66      return { fault: String(fault?.message ?? fault) };67    }68  }, [q.fill, q.code, q.base, q.number, level, q.pens, q.track, q.ring, q.wheel, q.sides, q.laps, q.reach, q.jitter, q.seed]);6970  useEffect(() => {71    if (!playing) return;72    let id = 0;73    let last = 0;74    const frame = (now) => {75      id = requestAnimationFrame(frame);76      const step = last ? Math.min(0.25, (now - last) / 1000) : 0;77      last = now;78      if (live.current >= 1) {79        setPlaying(false);80        return;81      }82      const turns = held.current?.read.turns || 1;83      if (step > 0) setAt((old) => Math.min(1, old + TURN * step / turns));84    };85    id = requestAnimationFrame(frame);86    const beat = setInterval(() => stamp({ at: place(live.current, 3) }), BEAT);87    return () => {88      cancelAnimationFrame(id);89      clearInterval(beat);90    };91  }, [playing]);9293  const settle = (next) => {94    const value = Math.min(1, Math.max(0, next));95    setPlaying(false);96    setAt(value);97    setQ({ at: Number(value.toFixed(3)) });98  };99100  const play = () => {101    if (playing) {102      settle(live.current);103      return;104    }105    if (live.current >= 1) setAt(0);106    setPlaying(true);107  };108109  const draw = (canvas) => {110    const view = held.current;111    if (!view) return;112    const { read, curves, samples, grid } = view;113    const [x0, y0, x1, y1] = read.frame;114    const wide = canvas.clientWidth;115    const tall = Math.max(160, Math.min(wide, Math.round((wide - 2 * PAD) * (y1 - y0) / (x1 - x0)) + 2 * PAD));116    const [ctx, w, h] = fit(canvas, tall);117    ctx.clearRect(0, 0, w, h);118    const scale = Math.min((w - 2 * PAD) / (x1 - x0), (h - 2 * PAD) / (y1 - y0));119    const ox = w / 2 - (x0 + x1) / 2 * scale;120    const oy = h / 2 + (y0 + y1) / 2 * scale;121    const X = (x) => ox + x * scale;122    const Y = (y) => oy - y * scale;123    const six = [ink.blue, ink.orange, ink.yellow, ink.green, ink.pink, ink.indigo];124    const colour = (k, kind) => (q.ink === 'one' ? ink.fg : q.ink === 'wheel' ? six[k % 6] : kind === 'fill' ? ink.blue : kind === 'void' ? ink.orange : ink.teal);125126    const track = new Path2D();127    read.outline.forEach(([x, y], i) => (i ? track.lineTo(X(x), Y(y)) : track.moveTo(X(x), Y(y))));128    if (read.closed) track.closePath();129    ctx.strokeStyle = ink.line;130    ctx.lineWidth = 1;131    ctx.setLineDash([4, 6]);132    ctx.stroke(track);133    ctx.setLineDash([]);134135    if (cover && !cover.fault) {136      const [dx, dy, radius] = cover.disc;137      const n = cover.side;138      const sheet = document.createElement('canvas');139      sheet.width = sheet.height = n;140      const image = new ImageData(n, n);141      const tint = rgb(ink.dim);142      for (let i = 0; i < n * n; i++) {143        if (cover.mask[i] !== 3) continue;144        image.data.set(tint, i * 4);145        image.data[i * 4 + 3] = SHADE;146      }147      sheet.getContext('2d').putImageData(image, 0, 0);148      ctx.imageSmoothingEnabled = false;149      ctx.drawImage(sheet, X(dx - radius), Y(dy + radius), 2 * radius * scale, 2 * radius * scale);150      ctx.strokeStyle = ink.dim;151      ctx.setLineDash([1, 5]);152      ctx.beginPath();153      ctx.arc(X(dx), Y(dy), radius * scale, 0, Math.PI * 2);154      ctx.stroke();155      ctx.setLineDash([]);156    }157158    const shown = Math.max(1, Math.round(at * (samples - 1)));159    ctx.lineWidth = 1.2;160    ctx.lineJoin = 'round';161    for (let k = 0; k < read.pencils; k++) {162      const base = k * samples * 2;163      const line = new Path2D();164      line.moveTo(X(curves[base]), Y(curves[base + 1]));165      for (let i = 1; i <= shown; i++) line.lineTo(X(curves[base + 2 * i]), Y(curves[base + 2 * i + 1]));166      ctx.strokeStyle = colour(k, read.seats[k][2]);167      ctx.stroke(line);168    }169170    const [cx, cy, phi] = m.spirograph_pose(q.track, q.ring, q.wheel, q.sides, q.laps, at);171    ctx.strokeStyle = ink.dim;172    ctx.beginPath();173    ctx.arc(X(cx), Y(cy), read.wheel * scale, 0, Math.PI * 2);174    ctx.stroke();175    const cell = read.cell * read.wheel * scale;176    ctx.save();177    ctx.translate(X(cx), Y(cy));178    ctx.rotate(-phi);179    ctx.globalAlpha = 0.35;180    ctx.fillStyle = ink.dim;181    for (let i = 0; i < grid.height; i++) {182      for (let j = 0; j < grid.width; j++) {183        if (grid.types[i * grid.width + j]) ctx.fillRect((j - grid.width / 2) * cell + 0.5, (i - grid.height / 2) * cell + 0.5, cell - 1, cell - 1);184      }185    }186    ctx.restore();187    const c = Math.cos(phi);188    const sn = Math.sin(phi);189    read.seats.forEach(([px, py, kind], k) => {190      ctx.fillStyle = colour(k, kind);191      ctx.beginPath();192      ctx.arc(X(cx + read.wheel * (px * c - py * sn)), Y(cy + read.wheel * (px * sn + py * c)), 2.5, 0, Math.PI * 2);193      ctx.fill();194    });195    ctx.fillStyle = ink.fg;196    ctx.beginPath();197    ctx.arc(X(cx), Y(cy), 2, 0, Math.PI * 2);198    ctx.fill();199  };200201  const circle = read && read.b > 0;202  const law = read203    ? circle204      ? `R/r = ${read.a}/${read.b}   closes after ${read.orbits} orbit${read.orbits === 1 ? '' : 's'}   ${read.fold}-fold   ${read.distinct} distinct curve${read.distinct === 1 ? '' : 's'} of ${read.pencils}`205      : q.track === 'line'206        ? `${read.distinct} shape${read.distinct === 1 ? '' : 's'} of ${read.pencils} pencils: on a line a seat's angle is a shift along the track, so curves of one radius are translates of one shape`207        : `${read.pencils} curves, ${read.orbits} lap${read.orbits === 1 ? '' : 's'}: no coincidence law on a polygon`208    : '';209  const filled = !cover210    ? ''211    : cover.fault212      ? cover.fault213      : `covered ${place(cover.covered * 100, 2)}% of the disc, of which wall ${place(cover.wall * 100, 2)}%   hole ${place(cover.hole * 100, 2)}%   winding ${place(cover.winding, 4)} of an exact ${place(cover.areas, 4)}   disc ${place(cover.disc[2], 3)} out, ${place(cover.disc[3], 3)} in   raster ${cover.side}`;214  const crossings = read && circle215    ? read.nodes === null216      ? "a seat sits at the wheel's centre, or at or past the threshold min(1, (a - b)/b) inside or 1 outside: the loops open or the seat crosses the centre path, and the node count is not the law's"217      : `nodes N = 2ab C(k,2) + k a(b-1) = ${read.nodes}`218    : '';219220  const controls = (221    <>222      <section>223        <h3>The wheel</h3>224        <Row>225          <Picker dimension={2} bases={[3, 2]} code={q.code} base={q.base} seeds={s} onChange={(patch) => setQ(patch)} />226          <Pick label="side" value={q.number} options={[[3, 3], [5, 5], [7, 7]]} onChange={(v) => setQ({ number: +v })} />227          <Slider label="level" value={level} min={1} max={cap} onChange={(v) => setQ({ level: v })} />228          <Pick label="pencils" value={q.pens} options={PENS} onChange={(v) => setQ({ pens: v })} />229          <Slider label="reach" value={q.reach} min={20} max={130} show={`${(q.reach / 100).toFixed(2)} r`} onChange={(v) => setQ({ reach: v })} />230          <Slider label="jitter" value={q.jitter} min={0} max={100} show={`${(q.jitter / 100).toFixed(2)} cells`} onChange={(v) => setQ({ jitter: v })} />231        </Row>232      </section>233      <section>234        <h3>The track</h3>235        <Row>236          <Pick label="track" value={q.track} options={TRACKS} onChange={(v) => setQ({ track: v })} />237          <Slider label="ring radius R" value={q.ring} min={1} max={CAPS.radius} onChange={(v) => setQ({ ring: v })} />238          <Slider label="wheel radius r" value={q.wheel} min={1} max={CAPS.radius} onChange={(v) => setQ({ wheel: v })} />239          <Pick label="sides" value={q.sides} options={SIDES.map((n) => [n, n])} onChange={(v) => setQ({ sides: +v })} />240          <Slider label="laps" value={q.laps} min={1} max={CAPS.laps} onChange={(v) => setQ({ laps: v })} />241        </Row>242      </section>243      <section>244        <h3>The draw</h3>245        <Row>246          <Slider label="drawn" value={Math.round(at * 1000)} min={0} max={1000} show={`${place(at * 100, 0)}%`} onChange={(v) => settle(v / 1000)} />247          <Btn primary on={playing} onClick={play}>{playing ? 'Stop' : 'Play'}</Btn>248          <Pick label="ink" value={q.ink} options={INKS} onChange={(v) => setQ({ ink: v })} />249          <Check label="fill the shape between the walls" checked={q.fill} onChange={(v) => setQ({ fill: v })} />250        </Row>251      </section>252    </>253  );254255  return (256    <Page crumb="spirograph" title="The spirograph"257      sub="A design is the wheel and its cells are the holes: a pencil in every one, and the wheel rolls without slipping on a straight line, inside or outside a circle, or around a polygon. The wheel's turn is its centre's path length over its radius, so every pencil draws a trochoid and the design draws them all at once. Two pencils draw the same curve exactly when a rotation of a full turn over b carries one seat onto the other, R/r = a/b in lowest terms, which the square lattice allows only by half turns when b is even and by quarter turns when four divides b. Inside the seat window, where every pencil sits off the wheel's centre and closer to it than min(1, A) wheel radii, A the centre path's radius in those units, (a - b)/b inside and (a + b)/b outside, no curve loops and no seat reaches the centre path: there two distinct curves cross 2ab times and one curve crosses itself a(b - 1) times, so the whole picture has 2ab C(k,2) + k a(b - 1) nodes with k the distinct curves, the design entering the count only through k. Every curve is also a wall no fluid crosses: pour fluid from outside the picture and it stops at the outer wall, stitched from the outermost arcs of the curves; pour it at the centre of the track and it stops at the inner wall. The shape between the two walls, pockets included, is what the fill shades, and covered is the share of the enclosing disc it takes, the chance a point dropped at random on the disc lands in the shape."258      controls={controls}259      foot={<>The seats, the track, the rolling, the trace, the closure, the coincidence law, the node count and the cover are computed in Rust; the page draws the polylines it is handed and the wheel where the crate poses it. The cover is a two-sided flood on a raster of the disc, so it counts the wall inside the shape and its digits carry a boundary error of the order of the curve length times the pixel, which is why the wall's own share is printed beside it. The winding readout checks the raster against Green's theorem and never the floods, which it cannot see: it is the mean signed winding number of the disc's pixel centres, read by scanline off the polylines, against pi b rho (rho -+ d^2/r) summed over the distinct curves and taken over the disc's area. What keeps a flood from leaking is instead the sample spacing, at most half a pixel, which leaves the wall unbroken. The pencil set is the design's own address set, so the picture is a rotation average of the design with an orbit added, the object of <a href="../radial">the radial page</a> and <a href="../spin">the spin page</a>; <a href="../tourbillon">the tourbillon</a> turns the layers of a stack instead of one tile.</>}>260      <Sketch draw={draw} deps={[view, cover, at, q.ink]} role="img" aria-label="The design rolled along its track, every pencil drawing its curve" />261      <Stats>262        <Stat label="wheel">{view?.name}</Stat>263        <Stat label="pencils">{read?.pencils}</Stat>264        <Stat label="curves">{read?.distinct}</Stat>265        <Stat label="nodes">{read && (read.nodes ?? '-')}</Stat>266        <Stat label="wheel turns">{read && place(read.turns, 2)}</Stat>267        <Stat label="covered">{cover && !cover.fault ? `${(cover.covered * 100).toFixed(0)}%` : '-'}</Stat>268        <Stat label="draw">{view && `${view.ms.toFixed(0)} ms`}</Stat>269      </Stats>270      {read && <pre>{[law, crossings, `pencils ${read.pencils}: ${read.fills} on fills, ${read.voids} on voids, ${read.corners} on corners   reach ${(q.reach / 100).toFixed(2)} r   cell ${place(read.cell, 4)} r`, `path length ${place(read.total, 2)}   samples per pencil ${view.samples}   drawn ${place(at * 100, 1)}%`,271        filled].filter(Boolean).join('\n')}</pre>}272      <Note error={error} />273    </Page>274  );275}276277mount(<App />);