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