index.jsx

8.0 kB · jsx · 157 lines

1import { useEffect, useMemo, useRef, useState } from 'react';2import { ready, ink, blit } from '../../lib/mrly.js';3import { mount, Page, Row, Pick, Slider, Check, Btn, Stats, Stat, Note, Group } from '../../lib/app.jsx';4import { Sketch } from '../../lib/draw.jsx';5import { useQuery, stamp } from '../../lib/query.js';6import { useSeeds, roll } from '../../lib/select.jsx';78const m = await ready();9const SIZE = 768;10const LABELS = 1000;11const LATTICES = ['square', 'hex'];12const MARKS = [['prime', 'primes'], ['twin', 'twin primes'], ['squarefree', 'squarefree'], ['mobius', 'Mobius sign']];13const FIRST = { lattice: 'square', side: 201, mark: 'prime', a: 4, b: -2, c: 41, faint: true };14const CLICK = new URLSearchParams(location.search).get('click');1516const shuffle = (seed) => {17  const [lattice, side, a, b, c] = roll(seed, [[0, LATTICES.length - 1], [21, 401], [1, 4], [-20, 20], [2, 97]]);18  return { lattice: LATTICES[lattice], side: side | 1, a, b, c: m.prime_from(c) };19};2021const first = (seeds) => (seeds.get() ? { ...FIRST, ...shuffle(seeds.get()) } : FIRST);2223function Num({ label, value, min, max, onChange }) {24  const [text, setText] = useState(String(value));25  useEffect(() => {26    if (+text !== value) setText(String(value));27  }, [value]);28  return (29    <label>{label} <input type="number" min={min} max={max} value={text} onChange={(e) => {30      setText(e.target.value);31      if (e.target.value !== '' && e.target.value !== '-') onChange(+e.target.value);32    }} /></label>33  );34}3536function App() {37  const s = useSeeds();38  const [look, save] = useQuery(first(s));39  const [picked, setPicked] = useState(null);40  const [error, setError] = useState(null);41  const kept = useRef({ pixels: null, poly: null, centres: null });4243  const view = useMemo(() => {44    try {45      const pixels = m.spiral_pixels(look.lattice, look.side, look.a, look.b, look.c, look.mark, look.faint, SIZE);46      const poly = JSON.parse(m.spiral_polynomial(look.lattice, look.side, look.a, look.b, look.c));47      const centres = poly.top <= LABELS ? m.spiral_centers(look.lattice, look.side, SIZE) : null;48      kept.current = { pixels, poly, centres };49      return { ...kept.current, error: null };50    } catch (error) {51      return { ...kept.current, error };52    }53  }, [look.lattice, look.side, look.mark, look.a, look.b, look.c, look.faint]);5455  const set = (patch) => {56    save(patch);57    if ('faint' in patch) stamp({ faint: patch.faint ? null : 0 });58    setPicked(null);59    setError(null);60  };6162  const draw = (canvas) => {63    const { pixels, centres } = view;64    if (!pixels) return;65    blit(canvas, pixels);66    const ctx = canvas.getContext('2d');67    if (centres) {68      const mono = getComputedStyle(document.body).getPropertyValue('--mono');69      const px = Math.min(14, SIZE / look.side * 0.42);70      ctx.font = `${px}px ${mono}`;71      ctx.textAlign = 'center';72      ctx.textBaseline = 'middle';73      ctx.fillStyle = ink.bg;74      for (let n = 1; 2 * n <= centres.length; n++) {75        const [x, y] = [centres[2 * n - 2], centres[2 * n - 1]];76        const light = pixels.rgba[(Math.floor(y) * SIZE + Math.floor(x)) * 4 + 1] > 100;77        ctx.fillStyle = light ? ink.bg : ink.dim;78        ctx.fillText(n, x, y + 1);79      }80    }81    if (picked) {82      ctx.strokeStyle = ink.fg;83      ctx.lineWidth = 2;84      ctx.beginPath();85      ctx.arc(picked.px, picked.py, picked.span / 2 + 3, 0, Math.PI * 2);86      ctx.stroke();87    }88  };8990  const hit = (x, y) => {91    if (!view.pixels) return;92    setError(null);93    try {94      setPicked(JSON.parse(m.spiral_at(look.lattice, look.side, x, y, SIZE)));95    } catch (error) {96      setError(error);97    }98  };99100  useEffect(() => {101    if (CLICK) hit(...CLICK.split(',').map(Number));102  }, []);103104  const poly = view.poly;105  const shown = poly ? poly.values.slice(0, 8).join(' ') + (poly.values.length > 8 ? ' …' : '') : '';106  const legend = poly ? `${look.a}${look.b < 0 ? '-' : '+'} ${Math.abs(look.b)} k ${look.c < 0 ? '-' : '+'} ${Math.abs(look.c)}: ${shown}` : '';107108  const controls = (109    <>110      <Group name="The spiral">111        <Pick label="lattice" value={look.lattice} options={LATTICES} onChange={(v) => set({ lattice: v })} />112        <Slider label="side" value={look.side} min={21} max={401} step={2} onChange={(v) => set({ side: v })} />113        <Pick label="mark" value={look.mark} options={MARKS} onChange={(v) => set({ mark: v })} />114        <Check label="faint composites" checked={look.faint} onChange={(v) => set({ faint: v })} />115      </Group>116      <Group name="The quadratic">117        <Num label="a" value={look.a} min={1} max={4} onChange={(v) => set({ a: v })} />118        <Num label="b" value={look.b} min={-20} max={20} onChange={(v) => set({ b: v })} />119        <Num label="c" value={look.c} min={2} max={97} onChange={(v) => set({ c: v })} />120      </Group>121      <Group name="Seed">122        <Btn onClick={() => set(shuffle(s.next()))}>Randomize</Btn>123      </Group>124    </>125  );126127  return (128    <Page crumb="ulam" title="The Ulam spiral"129      sub="Write 1 in the middle, then 2, 3, 4 and so on in a spiral, and paint the primes yellow. Nobody ordered them into lines, yet diagonals appear: every straight line through the spiral reads a quadratic a k² + b k + c, and some quadratics are rich in primes. Click a cell for its number and its factors, pick a quadratic to light it up, and wind the same numbers on hexagons to watch the lines bend into six directions."130      foot={<>On the square lattice ring <code>k</code> holds <code>8k</code> cells and ends at the odd square <code>(2k + 1)²</code> on the diagonal below right, so a straight line through the spiral picks one number per ring and reads a quadratic in <code>k</code>: a diagonal has <code>a = 4</code>, a line through the centre <code>a = 1</code> or <code>2</code>. Euler's <code>m² - m + 41</code> is prime for <code>m</code> from 0 to 40; at <code>m = 2k</code> it is <code>4k² - 2k + 41</code>, the line lit at the start, prime for its first 21 values and then at 1763 = 41 · 43 it breaks. On the hexagonal lattice ring <code>r</code> holds <code>6r</code> cells and ends at the centered hexagonal number <code>3r² + 3r + 1</code>, so its straight lines read quadratics with <code>a = 3</code>. Every cell is sieved and painted in Rust: yellow for the mark, orange where the quadratic lands on a prime, blue where it lands on a composite, pink for a Mobius value of minus one. The same primes are sieved, counted and found by the carpet stack on the <a href="../primes">primes</a> page.</>}131      controls={controls}>132      <div className="arena">133        <div className="panel">134          <h2>The sheet <span>{legend}</span></h2>135          <Sketch draw={draw} deps={[view, picked]} aria-label="The sheet, the whole numbers wound on the lattice with the marked ones lit" onClick={(event) => {136            const box = event.currentTarget.getBoundingClientRect();137            hit((event.clientX - box.left) * SIZE / box.width, (event.clientY - box.top) * SIZE / box.height);138          }} />139        </div>140      </div>141      <Stats>142        <Stat label="numbers">{poly?.top}</Stat>143        <Stat label="primes">{poly?.primes}</Stat>144        <Stat label="density">{poly && `${(poly.density * 100).toFixed(2)}%`}</Stat>145        <Stat label="hits on the quadratic">{poly && `${poly.hits} of ${poly.count}`}</Stat>146        <Stat label="share">{poly && `${(poly.share * 100).toFixed(1)}%`}</Stat>147        <Stat label="opening streak">{poly && (poly.count === 0 ? 'off the sheet' : poly.streak === poly.count ? `all ${poly.count} prime` : `${poly.streak} primes, then ${poly.values[poly.streak]}`)}</Stat>148        <Stat label="clicked">{picked && `${picked.n} on ring ${picked.ring} at ${picked.x}, ${picked.y}`}</Stat>149        <Stat label="verdict">{picked && (picked.n === 1 ? 'one, neither prime nor composite' : picked.prime ? 'prime' : 'composite')}</Stat>150        <Stat label="factors">{picked && (picked.factors.length ? picked.factors.map(([p, e]) => (e > 1 ? `${p}^${e}` : p)).join(' · ') : 'none')}</Stat>151      </Stats>152      <Note error={error ?? view.error} />153    </Page>154  );155}156157mount(<App />);