index.jsx
7.6 kB · jsx · 174 lines
1import { useEffect, useRef, useState } from 'react';2import { ready } from '../../lib/mrly.js';3import { mount, Page, Row, Pick, Slider, Text, Btn, Stats, Stat, Note } from '../../lib/app.jsx';4import { Pixels } from '../../lib/draw.jsx';5import { useQuery, stamp } from '../../lib/query.js';67const m = await ready();89const SIZES = [256, 512, 1024];10const SCHEDULES = [11 ['unspun', 'unspun'],12 ['degrees', 'layer k at k increments'],13 ['golden', 'golden angle'],14 ['primes', 'layer k at the k-th prime'],15 ['random', 'random, seeded'],16 ['gaussian', 'the Gaussian angle of n'],17];18const SETS = ['odd', 'primes', 'squarefree', 'prime powers'];19const WEIGHTS = ['plain', 'mobius', 'harmonic'];20const MODES = ['cells', 'edges', 'corners'];21const BLENDS = ['mean', 'sum', 'union', 'meet', 'parity', 'difference'];22const EYES = JSON.parse(m.tourbillon_eyes(12));23const STEP = 0.05;24const SWEEP = 6;25const BEAT = 1000;26const TIGHT = 1e-9;2728const place = (value, places) => (Number.isFinite(value) ? value.toFixed(places) : '-');2930const trim = (value) => String(Number(value.toFixed(6)));3132const eyeAt = (value) => EYES.findIndex(([angle]) => Math.abs(angle - value) < TIGHT);3334const label = ([angle, numer, denom]) => (denom === 1 ? trim(angle) : `${trim(angle)} = ${numer}/${denom}`);3536function App() {37 const [q, setQ] = useQuery({38 n: 55, r: 512, sched: 'degrees', inc: 1, set: 'odd',39 weights: 'plain', mode: 'cells', blend: 'mean', seed: 1,40 });41 const [inc, setInc] = useState(q.inc);42 const [typed, setTyped] = useState(trim(q.inc));43 const [playing, setPlaying] = useState(false);44 const live = useRef(inc);45 const held = useRef(null);46 live.current = inc;4748 let error = null;49 try {50 const clock = performance.now();51 const field = m.tourbillon(q.n, q.r, q.sched, inc, q.set, q.weights, q.mode, q.blend, q.seed);52 const read = JSON.parse(m.tourbillon_stats(field, q.r, q.n, q.sched, inc, q.set, q.weights, q.blend, q.seed));53 const signed = read.weighted && q.weights === 'mobius';54 const reach = Math.max(Math.abs(read.low), Math.abs(read.high));55 const pixels = m.paint_span(field, q.r, signed ? -reach : read.low, signed ? reach : read.high,56 signed ? 'diverge' : 'fire', read.layers + 1, false);57 held.current = { pixels, read, ms: performance.now() - clock };58 } catch (fault) {59 error = fault;60 }6162 useEffect(() => {63 if (!playing) return;64 let id = 0;65 let last = 0;66 const frame = (now) => {67 id = requestAnimationFrame(frame);68 const step = last ? Math.min(0.25, (now - last) / 1000) : 0;69 last = now;70 if (step > 0) setInc((old) => (old + SWEEP * step) % 360);71 };72 id = requestAnimationFrame(frame);73 const beat = setInterval(() => stamp({ inc: place(live.current, 2) }), BEAT);74 return () => {75 cancelAnimationFrame(id);76 clearInterval(beat);77 };78 }, [playing]);7980 const settle = (next) => {81 const value = Math.min(360, Math.max(0, Number(next.toFixed(6))));82 setPlaying(false);83 setInc(value);84 setTyped(trim(value));85 setQ({ inc: value });86 };8788 const write = (text) => {89 setPlaying(false);90 setTyped(text);91 const value = Number(text);92 if (!text.trim() || !Number.isFinite(value) || value < 0 || value > 360) return;93 setInc(value);94 setQ({ inc: value });95 };9697 const sweep = () => {98 if (playing) {99 settle(live.current);100 return;101 }102 setQ({ sched: 'degrees' });103 setPlaying(true);104 };105106 const view = held.current;107 const read = view?.read;108 const angles = read ? read.angles.map((value) => place(value, 1)).join(' ') : '';109 const peaks = read110 ? read.peaks.map(([x, y, value]) => ` ${place(x, 4)} ${place(y, 4)} ${place(value, 4)}`).join('\n')111 : '';112 const aside = read && !read.weighted && q.weights !== 'plain' ? ' weights unused' : '';113 const eye = eyeAt(inc);114115 const controls = (116 <>117 <section>118 <h3>The stack</h3>119 <Row>120 <Slider label="scales up to" value={q.n} min={3} max={99} step={2} onChange={(v) => setQ({ n: v })} />121 <Pick label="layers" value={q.set} options={SETS} onChange={(v) => setQ({ set: v })} />122 <Pick label="weights" value={q.weights} options={WEIGHTS} onChange={(v) => setQ({ weights: v })} />123 <Pick label="size" value={q.r} options={SIZES.map((v) => [v, v])} onChange={(v) => setQ({ r: +v })} />124 </Row>125 </section>126 <section>127 <h3>The spin</h3>128 <Row>129 <Pick label="schedule" value={q.sched} options={SCHEDULES} onChange={(v) => setQ({ sched: v })} />130 <Slider label="increment" value={inc} min={0} max={360} step={0.5} show={`${place(inc, 2)}°`}131 onChange={settle} />132 <Text label="degrees" value={playing ? place(inc, 2) : typed} onChange={write} />133 <Btn onClick={() => settle(inc - STEP)}>-</Btn>134 <Btn onClick={() => settle(inc + STEP)}>+</Btn>135 <Btn primary on={playing} onClick={sweep}>{playing ? 'Stop' : 'Play the increment'}</Btn>136 <Btn onClick={() => setQ({ sched: 'random', seed: q.seed + 1 })}>Randomize</Btn>137 </Row>138 </section>139 <section>140 <h3>The eyes</h3>141 <Row>142 <Pick label="quarter turn" value={eye < 0 ? '' : String(eye)}143 options={[['', 'off the lattice'], ...EYES.map((row, at) => [String(at), label(row)])]}144 onChange={(v) => { if (v !== '') { setQ({ sched: 'degrees' }); settle(EYES[+v][0]); } }} />145 </Row>146 </section>147 <section>148 <h3>The draw</h3>149 <Row>150 <Pick label="mode" value={q.mode} options={MODES} onChange={(v) => setQ({ mode: v })} />151 <Pick label="blend" value={q.blend} options={BLENDS} onChange={(v) => setQ({ blend: v })} />152 </Row>153 </section>154 </>155 );156157 return (158 <Page crumb="tourbillon" title="The tourbillon"159 sub="The odd parity carpet at the scales 1, 3, 5 and on, every layer turned about the centre by its own angle and masked to the inscribed disc, so every pixel sees every layer. Turn the layers as far as you like: the centre is the one point every rotation fixes, so its value never moves. The carpet is unchanged by a quarter turn, so at an increment of 90 a over q the layers fall into q angle classes and the spin goes dead."160 controls={controls}161 foot={<>The layer schedule, the selection, the weights, the quarter-turn lattice and the angle classes are all computed in Rust, the disc is masked and the layers merged there, and the page only paints the pixels it is handed. The unspun stack is the one the <a href="../moire">moire</a> page shows; spinning it is what breaks the shared grid, and the exact nodes that survive a turn are the Gaussian-integer angles. What the stack is, and why an address is not a construction, is in <a href="/research/stack/">the stack note</a>.</>}>162 {view && <Pixels data={view.pixels} style={{ maxWidth: 640 }} role="img" aria-label="The spun carpet stack inside its disc" />}163 <Stats>164 <Stat label="layers">{read?.layers}</Stat>165 <Stat label="pixels">{read && `${q.r} by ${q.r}`}</Stat>166 <Stat label="draw">{view && `${view.ms.toFixed(0)} ms`}</Stat>167 </Stats>168 {read && <pre>{`layers ${read.layers} scales ${read.scales.join(' ')}\nangles ${angles}\nmean ${place(read.mean, 4)} rms contrast ${place(read.rms, 4)} rms * sqrt(L) ${place(read.faded, 4)} centre ${place(read.centre, 4)}${aside}\nangle classes mod 90 ${read.classes} increments to a quarter turn ${read.period ?? 'none'} layer pairs sharing a class ${read.pairs}\ntop three maxima (x, y, value)\n${peaks}`}</pre>}169 <Note error={error} />170 </Page>171 );172}173174mount(<App />);