index.jsx

13.5 kB · jsx · 296 lines

1import { useEffect, useMemo, useReducer, useRef, useState } from 'react';2import { ready, ink, fit } from '../../lib/mrly.js';3import { mount, Page, Group, Pick, Slider, Text, Check, Btn, Stats, Stat, Note } from '../../lib/app.jsx';4import { Grid, Sketch } from '../../lib/draw.jsx';5import { Picker, useSeeds } from '../../lib/select.jsx';6import { useQuery } from '../../lib/query.js';78const m = await ready();910const FLAT = 96;11const LINE = 320;12const ROWS = 200;13const LIMIT = 512;14const TRACE = 320;1516const FIRST = { dim: 2, code: '7', side: 3, level: 1, birth: '3', survive: '23', wrap: true, seed: 'soup', density: 0.3, bseq: '', sseq: '' };1718const NAMES = [...m.life_sequences()];1920const SEEDS = [['soup', 'soup'], ['single', 'one cell'], ['blank', 'blank']];2122const PRESETS = [23  ['Conway', { dim: 2, code: '7', side: 3, level: 1, birth: '3', survive: '23', bseq: '', sseq: '' }],24  ['Cantor-Life', { dim: 1, code: '1', side: 3, level: 3, birth: '3', survive: '23', bseq: '', sseq: '' }],25  ['Menger row', { dim: 1, code: '3', side: 3, level: 2, birth: '3', survive: '23', bseq: '', sseq: '' }],26  ['nine-cell XOR', { dim: 2, code: '15', side: 3, level: 1, birth: '1357', survive: '02468', bseq: '', sseq: '' }],27  ['rule 150', { dim: 1, code: '1', side: 3, level: 1, birth: '1', survive: '02', bseq: '', sseq: '' }],28];2930const attempt = (fn) => {31  try {32    return { read: fn(), error: null };33  } catch (error) {34    return { read: null, error };35  }36};3738function parse(text) {39  const clean = String(text).trim();40  const parts = /[^0-9]/.test(clean) ? clean.split(/[^0-9]+/) : [...clean];41  const seen = new Set();42  for (const part of parts) if (part !== '') seen.add(+part);43  return Uint32Array.from([...seen].sort((a, b) => a - b));44}4546function counts(list, sequence) {47  return sequence || `[${[...list].join(' ')}]`;48}4950const alive = (types) => types.reduce((a, b) => a + b, 0);5152function ruleName(birth, survive, bseq, sseq, wrap) {53  return `rule birth ${counts(birth, bseq)}, survive ${counts(survive, sseq)}${wrap ? ', wrap' : ''}`;54}5556// THE WORLD5758function sow(dim, kind, density, tap) {59  const width = dim === 2 ? FLAT : LINE;60  const height = dim === 2 ? FLAT : 1;61  if (kind === 'soup') return m.life_noise(width, height, density, tap || 1);62  const types = new Uint8Array(width * height);63  if (kind === 'single') types[dim === 2 ? (height >> 1) * width + (width >> 1) : width >> 1] = 1;64  return types;65}6667function born(dim, kind, density, tap) {68  const types = sow(dim, kind, density, tap);69  const sheet = dim === 2 ? null : new Uint8Array(LINE * ROWS);70  if (sheet) sheet.set(types, 0);71  return { types, sheet, used: 1, pops: [alive(types)] };72}7374function advance(world, dim, next) {75  world.types = next;76  if (dim === 1) {77    if (world.used < ROWS) {78      world.sheet.set(next, world.used * LINE);79      world.used += 1;80    } else {81      world.sheet.copyWithin(0, LINE);82      world.sheet.set(next, (ROWS - 1) * LINE);83    }84  }85  world.pops.push(alive(next));86  if (world.pops.length > TRACE) world.pops.shift();87}8889// THE TRACE9091const trace = (pops) => (canvas) => {92  const [ctx, w, h] = fit(canvas, 120);93  ctx.clearRect(0, 0, w, h);94  const top = Math.max(1, ...pops);95  ctx.strokeStyle = ink.blue;96  ctx.lineWidth = 1.5;97  ctx.beginPath();98  pops.forEach((p, i) => {99    const x = 8 + (pops.length < 2 ? 0 : (i / (pops.length - 1)) * (w - 16));100    const y = h - 8 - (p / top) * (h - 20);101    if (i === 0) ctx.moveTo(x, y);102    else ctx.lineTo(x, y);103  });104  ctx.stroke();105  ctx.fillStyle = ink.dim;106  ctx.font = '11px ui-monospace, monospace';107  ctx.fillText(String(top), 8, 12);108};109110function App() {111  const taps = useSeeds();112  const [pick, set] = useQuery(FIRST);113  const [playing, setPlaying] = useState(false);114  const [age, setAge] = useState(0);115  const [verdict, setVerdict] = useState(null);116  const [error, setError] = useState(null);117  const [, force] = useReducer((x) => x + 1, 0);118119  const dim = pick.dim === 1 ? 1 : 2;120  const cap = Math.min(3, m.level_cap(pick.side, 1, dim === 2 ? 27 : 81));121  const level = Math.max(1, Math.min(pick.level, cap));122123  const mask = useMemo(() => attempt(() => m.life_mask(dim, pick.code.trim(), pick.side, level)), [dim, pick.code, pick.side, level]);124  const index = useMemo(() => (mask.read ? attempt(() => m.life_mask_index(mask.read.types, mask.read.width, mask.read.height)) : { read: null, error: null }), [mask.read]);125  const budget = mask.read ? alive(mask.read.types) : 8;126127  const world = useRef(null);128  world.current ??= born(dim, pick.seed, pick.density, taps.get());129130  const birth = parse(pick.birth);131  const survive = parse(pick.survive);132  const name = ruleName(birth, survive, pick.bseq, pick.sseq, pick.wrap);133  const maskName = attempt(() => m.name_of(pick.code.trim(), dim, 2)).read;134135  const reseed = (patch = {}) => {136    const next = { ...pick, ...patch };137    set(patch);138    setPlaying(false);139    setAge(0);140    setVerdict(null);141    setError(null);142    world.current = born(next.dim === 1 ? 1 : 2, next.seed, next.density, taps.get());143    force();144  };145146  const step = () => {147    if (!mask.read) return;148    try {149      const width = dim === 2 ? FLAT : LINE;150      const height = dim === 2 ? FLAT : 1;151      const next = m.life_next_masked(world.current.types, width, height, birth, survive, mask.read.types, mask.read.width, mask.read.height, pick.wrap);152      advance(world.current, dim, next);153      setAge((g) => g + 1);154      setError(null);155      force();156    } catch (thrown) {157      setPlaying(false);158      setError(thrown);159    }160  };161162  useEffect(() => {163    if (!playing) return;164    const timer = setInterval(step, 60);165    return () => clearInterval(timer);166  }, [playing, dim, pick.code, pick.side, level, pick.birth, pick.survive, pick.wrap, mask.read]);167168  const fate = () => {169    if (!mask.read) return;170    try {171      const width = dim === 2 ? FLAT : LINE;172      const height = dim === 2 ? FLAT : 1;173      const run = JSON.parse(m.life_run_masked(world.current.types, width, height, birth, survive, mask.read.types, mask.read.width, mask.read.height, pick.wrap, LIMIT));174      setVerdict(run);175      setError(null);176    } catch (thrown) {177      setError(thrown);178    }179  };180181  const toggle = (event) => {182    const box = event.target.getBoundingClientRect();183    const width = dim === 2 ? FLAT : LINE;184    const x = Math.floor((event.clientX - box.left) / box.width * width);185    if (dim === 2) {186      const y = Math.floor((event.clientY - box.top) / box.height * FLAT);187      world.current.types[y * FLAT + x] ^= 1;188    } else {189      world.current.types[x] ^= 1;190      world.current.sheet.set(world.current.types, (world.current.used - 1) * LINE);191    }192    setVerdict(null);193    force();194  };195196  const sequenced = (which, key, sequence) => {197    const patch = { [key]: sequence };198    if (sequence) patch[which] = [...m.life_sequence(sequence, budget)].join(' ');199    set(patch);200  };201202  const preset = (values) => {203    const patch = { ...values, level: values.level ?? 1 };204    reseed(patch);205  };206207  const wears = (values) => Object.entries(values).every(([key, value]) => (key === 'dim' ? dim : key === 'level' ? level : pick[key]) === value);208209  const controls = (210    <>211      <Group name="Run">212        <Btn primary onClick={() => setPlaying(!playing)}>{playing ? 'Pause' : 'Play'}</Btn>213        <Btn onClick={step}>Step</Btn>214        <Btn onClick={fate}>Run to fate</Btn>215        <Btn onClick={() => reseed()}>Reset</Btn>216        <Check label="wrap" checked={pick.wrap} onChange={(v) => set({ wrap: v })} />217      </Group>218      <Group name="The mask">219        <Pick label="dimension" value={dim} options={[[1, '1'], [2, '2']]} onChange={(v) => reseed({ dim: +v, code: +v === 1 ? '1' : '7' })} />220        <Picker dimension={dim} code={pick.code} seeds={taps} onChange={(patch) => set(patch)} />221        <Pick label="side" value={pick.side} options={[[3, 3], [5, 5], [7, 7], [9, 9]]} onChange={(v) => set({ side: +v, level: Math.min(level, Math.min(3, m.level_cap(+v, 1, dim === 2 ? 27 : 81))) })} />222        <Pick label="level" value={level} options={Array.from({ length: cap }, (_, i) => [i + 1, i + 1])} onChange={(v) => set({ level: +v })} />223      </Group>224      <Group name="The rule">225        <Pick label="birth from" value={pick.bseq} options={[['', 'by hand'], ...NAMES]} onChange={(v) => sequenced('birth', 'bseq', v)} />226        <Text label="birth counts" value={pick.birth} onChange={(v) => set({ birth: v, bseq: '' })} />227        <Pick label="survive from" value={pick.sseq} options={[['', 'by hand'], ...NAMES]} onChange={(v) => sequenced('survive', 'sseq', v)} />228        <Text label="survive counts" value={pick.survive} onChange={(v) => set({ survive: v, sseq: '' })} />229      </Group>230      <Group name="The seed">231        <Pick label="seed" value={pick.seed} options={SEEDS} onChange={(v) => reseed({ seed: v })} />232        <Slider label="density" value={pick.density} min={0.05} max={0.95} step={0.01} onChange={(v) => reseed({ seed: 'soup', density: v })} />233        <Btn onClick={() => { taps.next(); reseed({ seed: 'soup' }); }}>Randomize</Btn>234      </Group>235      <Group name="Presets">236        {PRESETS.map(([label, values]) => <Btn key={label} on={wears(values)} onClick={() => preset(values)}>{label}</Btn>)}237      </Group>238    </>239  );240241  const sheet = dim === 2242    ? { width: FLAT, height: FLAT, types: world.current.types }243    : { width: LINE, height: world.current.used, types: world.current.sheet.subarray(0, world.current.used * LINE) };244245  const reading = index.read === null ? 'unread' : index.read === 0 ? 'index 0: the offsets span no lattice' : index.read === 1 ? 'index 1: one lattice, nothing splits' : `index ${index.read}: ${index.read} interleaved copies`;246247  return (248    <Page crumb="mrlylife" title="mrlylife"249      sub="Life is one point of a family: pick the neighbourhood as a design rather than a ring, pick the birth and survival counts by hand or from a named sequence, and run it in one dimension or two. The mask is the object; the rule reads only how many of its cells are alive."250      foot={<>The kind is LIFE, outer-totalistic: a dead cell is born when its live neighbour count is in the birth list, a live cell stays when its count is in the survival list, and the mask says which cells are neighbours. Conway is the level-1 carpet `bang dim 2, code 7` with its centre popped, drawn plain on <a href="../life">the Life page</a>; the one-dimensional two-state radius-one masks are the elementary rules on <a href="../wolfram">the Wolfram page</a>. Menger-Life proper lives at dim 3 on the 20 offsets of `bang dim 3, code 23` and is not drawn here, so the Menger chip reads that tile one dimension down. The masks, the indices and every generation come out of the crates through wasm. The research page is <a href="/research/automata/">automata</a>.</>}251      controls={controls}>252253      <div className="arena">254        <div className="panel">255          <h2>the mask <span>a design, centre popped</span></h2>256          <Note error={mask.error} />257          {mask.read && <Grid grid={mask.read} on={ink.yellow} style={{ maxWidth: 200 }} aria-label="The neighbourhood mask" />}258          <Stats>259            <Stat label="mask">{`${maskName ?? `code ${pick.code.trim()}`}, side ${pick.side}, level ${level}`}</Stat>260            <Stat label="cells">{budget}</Stat>261            <Stat label="lattice">{reading}</Stat>262          </Stats>263          <p className="sub">The index is the decoupling index: the sublattice the mask offsets and the centre generate inside the whole lattice. An index above one means the board never mixes - it is that many interleaved copies of the same automaton, each blind to the others.</p>264        </div>265        <div className="panel">266          <h2>the rule <span>kind LIFE on this mask</span></h2>267          <Stats>268            <Stat label="name">{name}</Stat>269            <Stat label="birth">{[...birth].join(', ') || 'none'}</Stat>270            <Stat label="survive">{[...survive].join(', ') || 'none'}</Stat>271          </Stats>272          <p className="sub">Counts run from 0 to {budget}, the cell count of the mask. Type them as digits, as `1 3 5 7`, or draw them from a named sequence cut at the budget; a sequence side spells itself in the name, so `rule birth primes, survive [2 3]` is a rule and not a description of one.</p>273        </div>274      </div>275276      <div className="panel">277        <h2>{dim === 2 ? 'the board' : 'the space-time diagram'} <span>{dim === 2 ? `${FLAT} by ${FLAT}` : `${LINE} cells, newest row at the bottom`}</span></h2>278        <Grid grid={sheet} on={ink.green} style={{ maxWidth: 720 }} onClick={toggle} aria-label={dim === 2 ? 'The board, click a cell to toggle it' : 'The space-time diagram, click the newest row to toggle a cell'} />279        <Stats>280          <Stat label="generation">{age}</Stat>281          <Stat label="population">{world.current.pops[world.current.pops.length - 1]}</Stat>282          {verdict && <Stat label="fate">{verdict.fate} after {verdict.count}{verdict.loop ? `, loop ${verdict.loop}` : ''}</Stat>}283        </Stats>284        <Note error={error} />285      </div>286287      <div className="panel">288        <h2>the population <span>the last {TRACE} generations</span></h2>289        <Sketch draw={trace(world.current.pops)} deps={[age, dim]} className="bars" aria-label="The population against generation" />290        <p className="sub">Run to fate replays the board for at most {LIMIT} generations and reports death, a frozen board, a loop with its period, or a timeout. Every value on this page is a link: the dimension, the code, the side, the level, the two count lists, the wrap and the seed all live in the address bar.</p>291      </div>292    </Page>293  );294}295296mount(<App />);