index.jsx
11.5 kB · jsx · 219 lines
1import { useEffect, useMemo, useState } from 'react';2import { ready, ink, fit } from '../../lib/mrly.js';3import { mount, Page, Group, Pick, Slider, Text, Btn, Stats, Stat, Note } from '../../lib/app.jsx';4import { Grid, Pixels, Sketch } from '../../lib/draw.jsx';5import { Picker, useSeeds } from '../../lib/select.jsx';6import { useQuery } from '../../lib/query.js';78const m = await ready();910const SIZES = [128, 256, 512];11const SIDES = [3, 5, 7, 9];12const DEEPEST = 5;13const SHADES = 64;14const PAUSE = 120;1516const BUGS = { blo: '0.283', bhi: '0.375', slo: '0.283', shi: '0.483' };17const CONWAY = { code: '7', side: 3, level: 1, blo: '0.375', bhi: '0.375', slo: '0.25', shi: '0.375' };1819const PRESETS = [['Bugs', BUGS], ['Conway', CONWAY]];2021const FIRST = { code: '7', side: 3, level: 3, ...BUGS, size: 256, steps: 64, density: 0.45, soup: 7 };2223const attempt = (fn) => {24 try {25 return { read: fn(), error: null };26 } catch (error) {27 return { read: null, error };28 }29};3031const alive = (types) => types.reduce((a, b) => a + b, 0);3233const share = (text) => {34 const value = String(text).trim() === '' ? NaN : Number(text);35 return Number.isFinite(value) ? value : NaN;36};3738const glow = (spectrum, size) => m.paint_span(spectrum, size, 0, 1, 'fire', SHADES, false);3940// THE PROFILE4142const rings = (still, mask) => (canvas) => {43 const [ctx, w, h] = fit(canvas, 160);44 ctx.clearRect(0, 0, w, h);45 const left = 8, right = w - 8, top = 16, floor = h - 18;46 const span = Math.max(still.profile.length, mask.profile.length) - 1;47 const x = (k) => left + (k / span) * (right - left);48 const curve = (profile, hue, width) => {49 const peak = Math.max(1e-9, ...profile.slice(1));50 ctx.strokeStyle = hue;51 ctx.lineWidth = width;52 ctx.beginPath();53 profile.forEach((v, k) => {54 if (k === 0) return;55 const y = floor - (Math.min(v, peak) / peak) * (floor - top);56 if (k === 1) ctx.moveTo(x(k), y);57 else ctx.lineTo(x(k), y);58 });59 ctx.stroke();60 };61 curve(mask.profile, ink.yellow, 1);62 curve(still.profile, ink.blue, 1.5);63 if (still.peak_ring > 0) {64 ctx.strokeStyle = ink.pink;65 ctx.lineWidth = 1;66 ctx.setLineDash([3, 3]);67 ctx.beginPath();68 ctx.moveTo(x(still.peak_ring), top - 6);69 ctx.lineTo(x(still.peak_ring), floor);70 ctx.stroke();71 ctx.setLineDash([]);72 }73 ctx.fillStyle = ink.dim;74 ctx.font = '11px ui-monospace, monospace';75 ctx.fillText('ring 1', left, h - 5);76 ctx.textAlign = 'right';77 ctx.fillText(`ring ${span}`, right, h - 5);78 ctx.textAlign = 'left';79 ctx.fillStyle = ink.pink;80 if (still.peak_ring > 0) ctx.fillText(`ring ${still.peak_ring}`, Math.min(x(still.peak_ring) + 4, right - 40), top - 6);81};8283function App() {84 const taps = useSeeds();85 const [pick, set] = useQuery(FIRST);86 const [world, setWorld] = useState(null);8788 const code = pick.code.trim();89 const size = SIZES.includes(pick.size) ? pick.size : FIRST.size;90 const cap = Math.min(DEEPEST, m.level_cap(pick.side, 1, size));91 const level = Math.max(1, Math.min(pick.level, cap));92 const windows = [share(pick.blo), share(pick.bhi), share(pick.slo), share(pick.shi)];93 const unread = windows.some((v) => Number.isNaN(v)) ? new Error('every window edge is a fraction of the mask budget, a number between 0 and 1.') : null;9495 const kernel = useMemo(() => attempt(() => m.chladni_kernel(code, pick.side, level, size)), [code, pick.side, level, size]);96 const budget = kernel.read ? alive(kernel.read.types) : 0;97 const maskSpectrum = useMemo(() => kernel.read && glow(m.chladni_spectrum(kernel.read.types, size), size), [kernel.read]);98 const maskProfile = useMemo(() => kernel.read && JSON.parse(m.chladni_profile(kernel.read.types, size)), [kernel.read]);99100 const run = () => {101 if (unread) return setWorld({ types: null, age: 0, error: unread });102 const made = attempt(() => m.chladni_run(code, pick.side, level, ...windows, size, pick.steps, pick.density, pick.soup));103 setWorld({ types: made.read ? made.read.types : null, age: 0, error: made.error });104 };105106 const step = () => {107 if (!world?.types || unread) return;108 const made = attempt(() => m.chladni_next(world.types, size, code, pick.side, level, ...windows));109 setWorld(made.read ? { types: made.read, age: world.age + 1, error: null } : { ...world, error: made.error });110 };111112 const key = [code, pick.side, level, ...windows, size, pick.steps, pick.density, pick.soup].join(' ');113 useEffect(() => {114 const timer = setTimeout(run, PAUSE);115 return () => clearTimeout(timer);116 }, [key]);117118 const still = useMemo(() => (world?.types ? { width: size, height: size, types: world.types } : null), [world]);119 const stillSpectrum = useMemo(() => still && glow(m.chladni_spectrum(still.types, size), size), [still]);120 const stillProfile = useMemo(() => still && JSON.parse(m.chladni_profile(still.types, size)), [still]);121 const live = still ? alive(still.types) : 0;122123 const wears = (values) => Object.entries(values).every(([k, v]) => (k === 'level' ? level : pick[k]) === v);124125 const counts = (lo, hi) => {126 if (!budget || Number.isNaN(lo) || Number.isNaN(hi)) return 'none';127 const from = Math.ceil(lo * budget - 1e-9), to = Math.floor(hi * budget + 1e-9);128 return from > to ? 'none' : from === to ? `${from}` : `${from} to ${to}`;129 };130131 const controls = (132 <>133 <Group name="Run">134 <Btn primary onClick={run}>Run</Btn>135 <Btn onClick={step}>Step</Btn>136 <Pick label="size" value={size} options={SIZES.map((s) => [s, s])} onChange={(v) => set({ size: +v, level: Math.min(level, Math.min(DEEPEST, m.level_cap(pick.side, 1, +v))) })} />137 <Slider label="steps" value={pick.steps} min={1} max={128} onChange={(v) => set({ steps: v })} />138 <Slider label="density" value={pick.density} min={0.05} max={0.95} step={0.01} onChange={(v) => set({ density: v })} />139 <Slider label="soup" value={pick.soup} min={1} max={99} onChange={(v) => set({ soup: v })} />140 </Group>141 <Group name="The mask">142 <Picker dimension={2} code={pick.code} seeds={taps} onChange={(patch) => set(patch)} />143 <Pick label="side" value={pick.side} options={SIDES.map((s) => [s, s])} onChange={(v) => set({ side: +v, level: Math.min(level, Math.min(DEEPEST, m.level_cap(+v, 1, size))) })} />144 <Pick label="level" value={level} options={Array.from({ length: cap }, (_, i) => [i + 1, i + 1])} onChange={(v) => set({ level: +v })} />145 </Group>146 <Group name="The rule">147 <Text label="birth from" value={pick.blo} onChange={(v) => set({ blo: v })} />148 <Text label="birth to" value={pick.bhi} onChange={(v) => set({ bhi: v })} />149 <Text label="survive from" value={pick.slo} onChange={(v) => set({ slo: v })} />150 <Text label="survive to" value={pick.shi} onChange={(v) => set({ shi: v })} />151 {PRESETS.map(([label, values]) => <Btn key={label} on={wears(values)} onClick={() => set(values)}>{label}</Btn>)}152 </Group>153 </>154 );155156 return (157 <Page crumb="chladni" title="chladni"158 sub="A soup run under a Larger-than-Life rule on a big design mask settles into a still with a grain of its own. The mask's spectrum and the still's are drawn side by side, and the ring where the still's spectrum peaks reads the wavelength the rule prefers."159 foot={<>The mask is a design at its side and level with its centre popped, m cells in all; the rule is two closed windows on the fraction count / m: a dead cell is born inside the birth window, a live cell is kept inside the survive window, and the count is the mask laid on the torus, read by FFT convolution. Bugs is Evans' rule on the radius-5 box, birth 34 to 45 and survive 34 to 58 of 120, kept here as fractions so it moves to any mask; Conway is 3/8 3/8 2/8 3/8 on the Moore mask, the level-1 carpet. The spectrum is the log magnitude of the field's transform with the zero frequency at the centre, the profile its mean over rings of radius k, and ring k on a torus of side N is the wavelength N / k. The same masks and counts run without the fractions on <a href="../mrlylife">the mrlylife page</a>, and the mask's own modes are drawn on <a href="../modes">the modes page</a>. The research page is <a href="/research/automata/">automata</a>.</>}160 controls={controls}>161162 <div className="arena">163 <div className="panel">164 <h2>the mask <span>a design, centre popped, on the torus</span></h2>165 <Note error={kernel.error} />166 {kernel.read && <Grid grid={kernel.read} on={ink.yellow} aria-label="The neighbourhood mask centred on the torus" />}167 <Stats>168 <Stat label="mask">{`code ${code} side ${pick.side} level ${level}`}</Stat>169 <Stat label="span">{kernel.read ? pick.side ** level : 0}</Stat>170 <Stat label="cells">{budget}</Stat>171 <Stat label="birth">{counts(windows[0], windows[1])}</Stat>172 <Stat label="survive">{counts(windows[2], windows[3])}</Stat>173 </Stats>174 </div>175 <div className="panel">176 <h2>the still <span>{`${size} by ${size}, wrapped`}</span></h2>177 <Note error={world?.error ?? unread} />178 {still && <Grid grid={still} on={ink.green} aria-label="The soup after the run" />}179 <Stats>180 <Stat label="generation">{world ? pick.steps + world.age : 0}</Stat>181 <Stat label="live">{live}</Stat>182 <Stat label="share"><span className="num">{(live / (size * size)).toFixed(3)}</span></Stat>183 </Stats>184 </div>185 </div>186187 <div className="arena">188 <div className="panel">189 <h2>the mask's spectrum <span>log magnitude, DC at the centre</span></h2>190 {maskSpectrum && <Pixels data={maskSpectrum} role="img" aria-label="The log spectrum of the mask" />}191 <Stats>192 <Stat label="peak ring">{maskProfile ? maskProfile.peak_ring : 0}</Stat>193 <Stat label="wavelength"><span className="num">{maskProfile ? maskProfile.wavelength.toFixed(2) : '0.00'}</span></Stat>194 </Stats>195 </div>196 <div className="panel">197 <h2>the still's spectrum <span>the same transform of the still</span></h2>198 {stillSpectrum && <Pixels data={stillSpectrum} role="img" aria-label="The log spectrum of the still" />}199 <Stats>200 <Stat label="peak ring">{stillProfile ? stillProfile.peak_ring : 0}</Stat>201 <Stat label="wavelength"><span className="num">{stillProfile ? stillProfile.wavelength.toFixed(2) : '0.00'}</span></Stat>202 </Stats>203 </div>204 </div>205206 <div className="panel">207 <h2>the ring profile <span>mean log magnitude at radius k, the still in blue, the mask in yellow</span></h2>208 {stillProfile && maskProfile && <Sketch draw={rings(stillProfile, maskProfile)} deps={[stillProfile, maskProfile]} className="bars" aria-label="The ring profile of both spectra with the peak ring marked" />}209 <Stats>210 <Stat label="peak ring">{stillProfile ? stillProfile.peak_ring : 0}</Stat>211 <Stat label="wavelength"><span className="num">{stillProfile ? `${stillProfile.wavelength.toFixed(2)} cells` : 'none'}</span></Stat>212 </Stats>213 <p className="sub">Each curve is scaled to its own peak past ring 0, so the two are read for shape, not height. The dashed line is the still's peak ring; on an empty field the profile is flat and the tie goes to ring 1, so a dead soup reads wavelength {size}. Every value on this page is a link: the code, the side, the level, the four window edges, the size, the steps, the density and the soup all live in the address bar.</p>214 </div>215 </Page>216 );217}218219mount(<App />);