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