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