index.jsx
16.2 kB · jsx · 340 lines
1import { useEffect, useMemo, useRef, useState } from 'react';2import { ready, ink } from '../../lib/mrly.js';3import { board, bars, line, axis, tag } from '../../lib/chart.js';4import { faces } from '../../lib/stage.js';5import { mount, Page, Row, Pick, Slider, Check, Btn, Stats, Stat, Note, Group } from '../../lib/app.jsx';6import { Grid, Markup, Sketch } from '../../lib/draw.jsx';7import { Stage } from '../../lib/stage.jsx';8import { Picker, useSeeds, roll } from '../../lib/select.jsx';9import { useQuery, stamp } from '../../lib/query.js';1011const m = await ready();12const MAX_SLOTS = 8;13const DIMS = [[2, 'the plane'], [3, 'the cube'], [6, 'the hexagon']];14const PROJECTIONS = [['cut', 'the middle slice'], ['pro', 'three facing sides'], ['iso', 'the isometric skin']];15const SIDE = { 2: 243, 3: 64, 6: 32 };16const DROP = { 2: 1, 3: 2, 6: 1 };17const CUBES = 30000;18const TRIANGLES = 12000;19const WIDE = 220;2021const TOWERS = {22 gasket: { dim: 2, letters: [['7', 2, 2], ['7', 2, 2], ['7', 2, 2], ['7', 2, 2], ['7', 2, 2], ['7', 2, 2]] },23 carpet: { dim: 2, letters: [['495', 3, 3], ['495', 3, 3], ['495', 3, 3], ['495', 3, 3], ['495', 3, 3]] },24 order: { dim: 2, letters: [['7', 2, 2], ['9', 2, 2], ['7', 2, 2], ['9', 2, 2], ['7', 2, 2], ['9', 2, 2]] },25 doctest: { dim: 2, letters: [['7', 2, 3], ['14', 2, 7], ['9', 2, 5]] },26 sponge: { dim: 3, letters: [['23', 2, 3], ['23', 2, 3], ['23', 2, 3]] },27 pair: { dim: 3, letters: [['23', 2, 3], ['9', 2, 3], ['23', 2, 3]] },28 shadow: { dim: 6, letters: [['23', 2, 3], ['23', 2, 3], ['23', 2, 3]] },29};3031const NAMES = [32 ['gasket', 'the gasket, one letter six times'],33 ['carpet', 'the carpet at base three'],34 ['order', 'two letters alternating'],35 ['doctest', 'the doctest word, three sides'],36 ['sponge', 'the sponge in the cube'],37 ['pair', 'two cube letters'],38 ['shadow', 'the sponge tower as a hexagon'],39];4041const FIRST = { dim: 2, proj: 'cut', blocks: 6 };42const START = { 2: 'gasket', 3: 'sponge', 6: 'shadow' };4344const attempt = (fn) => {45 try {46 return { ...fn(), error: null };47 } catch (error) {48 return { error };49 }50};5152const side = (value) => Math.min(16, Math.max(2, +value || 2));5354function label(axes, codes, numbers, bases) {55 if (codes.length === 1) return `${m.name_of(codes[0], axes, bases[0])} at side ${numbers[0]}`;56 return m.magic_name(codes, numbers, bases, axes);57}5859function firstSlots(params, dim) {60 const list = [];61 if (params.has('w')) {62 try {63 const read = JSON.parse(m.magic_parse(params.get('w')));64 for (const [i, code] of read.codes.entries()) list.push({ code, base: read.bases[i], number: read.numbers[i] });65 for (let i = 0; i < MAX_SLOTS; i++) stamp({ [`l${i}code`]: null, [`l${i}base`]: null, [`l${i}n`]: null });66 } catch {67 list.length = 0;68 }69 } else {70 for (let i = 0; i < MAX_SLOTS; i++) {71 if (!params.has(`l${i}code`)) break;72 list.push({ code: params.get(`l${i}code`), base: +(params.get(`l${i}base`) ?? 2), number: +(params.get(`l${i}n`) ?? 3) });73 }74 }75 if (list.length >= 2) return list;76 return TOWERS[START[dim]].letters.map(([code, base, number]) => ({ code, base, number }));77}7879// BLOCKS8081function count(dim, codes, numbers, bases, proj) {82 if (codes.length === 1) {83 const reps = dim === 3 ? [1, 1, 1] : [1, 1];84 const one = JSON.parse(m.tile_census(codes[0], numbers[0], 1, bases[0], dim, proj, reps, false));85 return {86 span: String(dim === 6 ? one.sheet[0] : one.side),87 cells: one.cells,88 fills: one.fills,89 exposed: one.exposed,90 ratio: one.ratio,91 wide: one.sheet[0],92 };93 }94 if (dim === 6) {95 const hex = JSON.parse(m.magic_hex_census(codes, numbers, bases, proj));96 return {97 span: String(hex.grid[0]),98 cells: String(hex.triangles),99 fills: String(hex.fills),100 exposed: String(hex.exposed),101 ratio: hex.ratio,102 wide: hex.grid[0],103 };104 }105 const census = JSON.parse(m.magic_census(codes, numbers, dim, bases));106 return {107 span: census.side,108 cells: census.cells,109 fills: census.fill,110 exposed: dim === 2 ? m.magic_perimeter(codes, numbers, bases) : m.magic_surface(codes, numbers, bases),111 ratio: census.ratio,112 wide: Number(census.side),113 };114}115116function picture(dim, codes, numbers, bases, proj, scale) {117 const one = codes.length === 1;118 if (dim === 2) return { grid: one ? m.two_grid(codes[0], numbers[0], 1, 0, bases[0]) : m.magic_grid(codes, numbers, bases) };119 if (dim === 3) return { buffer: one ? m.three_faces(codes[0], numbers[0], 1, bases[0]) : m.magic_faces(codes, numbers, bases) };120 return { svg: one ? m.hex_svg(codes[0], numbers[0], 1, bases[0], proj, scale) : m.magic_hex(codes, numbers, bases, proj, scale) };121}122123const density = (dim, block) => Number(block.exposed) / Number(block.span) ** DROP[dim];124125function running(values) {126 const out = [];127 let sum = 0;128 for (const value of values) {129 sum += value;130 out.push(sum);131 }132 const last = out[out.length - 1] || 1;133 return out.map((value, i) => [(i + 0.5) / out.length, value / last]);134}135136function App() {137 const seeds = useSeeds();138 const [q, set] = useQuery(FIRST);139 const dim = START[q.dim] ? q.dim : 2;140 const [slots, setSlots] = useState(() => firstSlots(new URLSearchParams(location.search), dim));141 const [preset, setPreset] = useState(START[FIRST.dim]);142 const [spin, setSpin] = useState(true);143 const live = useRef(null);144145 const solid = dim !== 2;146 const codes = slots.map((slot) => slot.code.trim());147 const numbers = slots.map((slot) => side(slot.number));148 const bases = slots.map((slot) => (solid ? 2 : slot.base));149 const axes = solid ? 3 : 2;150 const name = attempt(() => ({ text: m.magic_name(codes, numbers, bases, axes) })).text ?? '';151 const key = attempt(() => ({ text: m.magic_key(codes, numbers, bases, axes) })).text ?? '';152 const sig = `${dim}|${JSON.stringify(slots)}`;153154 const cap = attempt(() => ({ top: m.magic_cap(numbers, axes, SIDE[dim]) })).top ?? 1;155 const depth = Math.max(1, Math.min(q.blocks || 1, cap));156157 useEffect(() => {158 const values = { w: key || null };159 for (let i = 0; i < MAX_SLOTS; i++) {160 values[`l${i}code`] = i < slots.length ? codes[i] : null;161 values[`l${i}base`] = i < slots.length && !solid ? bases[i] : null;162 values[`l${i}n`] = i < slots.length ? numbers[i] : null;163 }164 stamp(values);165 }, [sig]);166167 const word = useMemo(() => attempt(() => ({ census: JSON.parse(m.magic_census(codes, numbers, axes, bases)) })), [sig]);168169 const tower = useMemo(() => {170 const blocks = [];171 let error = null;172 for (let k = 1; k <= depth; k++) {173 try {174 const cut = [codes.slice(0, k), numbers.slice(0, k), bases.slice(0, k)];175 const read = count(dim, ...cut, q.proj);176 if (dim === 3 && Number(read.fills) > CUBES) throw new Error(`block ${k} holds ${read.fills} cubes, more than this page draws; drop a letter or lower a side.`);177 if (dim === 6 && Number(read.cells) > TRIANGLES) throw new Error(`block ${k} holds ${read.cells} triangles, more than this page draws; drop a letter or lower a side.`);178 const scale = Math.max(1, Math.round(WIDE / read.wide));179 blocks.push({ k, word: label(axes, ...cut), ...read, ...picture(dim, ...cut, q.proj, scale) });180 } catch (fault) {181 error = fault;182 break;183 }184 }185 return { blocks, error };186 }, [sig, dim, q.proj, depth]);187188 const patch = (i, values) => setSlots(slots.map((slot, k) => (k === i ? { ...slot, ...values } : slot)));189190 const load = (key) => {191 setPreset(key);192 setSlots(TOWERS[key].letters.map(([code, base, number]) => ({ code, base, number })));193 set({ dim: TOWERS[key].dim });194 };195196 const shift = (value) => {197 seeds.drop();198 load(START[+value]);199 };200201 const randomize = () => {202 const seed = seeds.next();203 const drawn = m.random_codes(solid ? 3 : 2, 2, seed, slots.length);204 const sides = roll(seed, slots.map(() => [2, solid ? 4 : 6]));205 setSlots(slots.map((slot, i) => ({ code: drawn[i], base: 2, number: sides[i] })));206 };207208 const swap = (i) => {209 const next = [...slots];210 const at = (i + 1) % next.length;211 [next[i], next[at]] = [next[at], next[i]];212 setSlots(next);213 };214215 const turn = (on) => {216 setSpin(on);217 if (live.current) live.current.spin = on ? 0.004 : 0;218 };219220 const onStage = (stage) => {221 live.current = stage;222 stage.clear();223 stage.spin = spin ? 0.004 : 0;224 const drawn = dim === 3 ? tower.blocks : [];225 drawn.forEach((block, i) => {226 const mesh = faces(block.buffer, ink.blue, 1);227 mesh.scale.setScalar(0.92 / drawn.length);228 mesh.position.set(-1 + (2 * i + 1) / drawn.length, 0, 0);229 stage.add(mesh);230 });231 };232233 const drawChart = (canvas) => {234 const rows = tower.blocks;235 const b = board(canvas, 240, { left: 16, right: 16 });236 if (!rows.length) return;237 axis(b, [[0, 'block 1'], [1, `block ${rows.length}`]], { wall: true });238 bars(b, rows.map((row) => row.ratio), { color: ink.dim, inset: 4 });239 line(b, running(rows.map((row) => row.ratio)), ink.yellow, { width: 1.8, dots: 3 });240 line(b, running(rows.map((row) => density(dim, row))), ink.blue, { width: 1.8, dots: 3 });241 tag(b, 'each running total against its own last value', ink.dim, 'right');242 };243244 const art = (block) => {245 if (dim === 2) return <Grid grid={block.grid} on={ink.yellow} role="img" aria-label={`block ${block.k}, ${block.word}`} />;246 if (dim === 6) return <Markup svg={block.svg} role="img" aria-label={`block ${block.k}, ${block.word}`} />;247 return null;248 };249250 const census = word.census ?? {};251 const letters = census.letters ?? [];252 const shrinks = letters.length > 0 && letters.some((letter) => letter.fill !== letter.cells);253 const climbs = shrinks && census.dimension > DROP[dim];254255 const controls = (256 <>257 <Group name="Tower">258 <Row>259 <Pick label="dimension" value={dim} options={DIMS} onChange={shift} />260 <Pick label="tower" value={preset} options={NAMES} onChange={load} />261 <Slider label="blocks" value={depth} min={1} max={cap} show={`${depth}/${cap}`} onChange={(v) => set({ blocks: v })} />262 <Btn onClick={randomize}>Randomize</Btn>263 {dim === 6 && <Pick label="projection" value={q.proj} options={PROJECTIONS} onChange={(v) => set({ proj: v })} />}264 {dim === 3 && <Check label="spin" checked={spin} onChange={turn} />}265 </Row>266 </Group>267 <Group name="Letters">268 {slots.map((slot, i) => (269 <Row key={i}>270 <span className="badge">letter {i + 1}</span>271 <span className="set">272 <Picker dimension={solid ? 3 : 2} bases={solid ? [2] : [2, 3]} code={slot.code} base={bases[i]} seeds={seeds} button={false} onChange={(values) => patch(i, values)} />273 </span>274 <label>side <input type="number" value={slot.number} min={2} max={16} onChange={(e) => patch(i, { number: e.target.value })} /></label>275 <button disabled={slots.length < 2} onClick={() => swap(i)}>swap next</button>276 <button disabled={slots.length < 3} onClick={() => setSlots(slots.filter((one, k) => k !== i))}>remove</button>277 </Row>278 ))}279 <Row>280 <button disabled={slots.length >= MAX_SLOTS} onClick={() => setSlots([...slots, { ...slots[slots.length - 1] }])}>add a letter</button>281 </Row>282 </Group>283 </>284 );285286 return (287 <Page crumb="tower" title="Finite volume, infinite surface" controls={controls}288 sub="Gabriel's tower. The tile lays one design side by side on every axis; hold every axis but one to a single copy and let the word rise a letter per block, and the blocks stand at the same physical side while the design inside them deepens. A block's volume is its fill fraction, which falls by one letter's share at every step, so the tower's volume converges. Its surface is the exposed count over the side, and once a letter is under one and the design's dimension passes dim - 1 that density climbs without bound. Finite volume, infinite surface, the fractal cousin of Gabriel's horn."289 foot={<>Every block is a prefix of the word, built in Rust before a pixel is drawn: the plane block is the word's grid, the cube block its exposed faces, the hexagon block the projected skin of the same cube word. Every printed number is a Rust number or a ratio of two Rust integers with both operands in view. The word, its letters and the products they multiply are on <a href="../words">the words</a>; the same design laid side by side on every axis instead of one is <a href="../tile">the tile</a>. The construction of a word and the fill law behind the geometric decay are written up in <a href="/research/magic/">the research note on magic words</a>.</>}>290 <p className="badge dim">{name}</p>291 <Stage hidden={dim !== 3} role="img" aria-label="The tower" deps={[tower]} onStage={onStage} />292 <div className="tower">293 {tower.blocks.map((block) => (294 <div className="block" key={block.k}>295 {art(block)}296 <div className="stats">297 <span>block {block.k} <b>{block.word}</b></span>298 <Stat label={dim === 6 ? 'mesh wide' : 'side'}>{block.span}</Stat>299 <span>{dim === 6 ? 'inked' : 'fills'} / {dim === 6 ? 'triangles' : 'cells'} <b>{block.fills}</b> / <b>{block.cells}</b></span>300 <Stat label="volume">{block.ratio.toFixed(6)}</Stat>301 <Stat label="exposed">{block.exposed}</Stat>302 </div>303 </div>304 ))}305 </div>306 <Note error={tower.error ?? word.error} />307 <Stats>308 {name && <Stat label="name">{name}</Stat>}309 <Stat label="letters">{census.length}</Stat>310 <Stat label="blocks">{depth} of {cap}</Stat>311 <Stat label="word side">{census.side}</Stat>312 <Stat label="word cells">{census.cells}</Stat>313 <Stat label="word fill">{census.fill}</Stat>314 <Stat label="density">{census.ratio?.toFixed(6)}</Stat>315 <Stat label="dimension">{census.dimension?.toFixed(6)}</Stat>316 {dim !== 6 && <Stat label="dim - 1">{DROP[dim]}</Stat>}317 </Stats>318 <Stats>319 {letters.map((letter, i) => (320 <span key={i} className="badge">{i + 1} <b>{letter.name}</b> side {letter.number} fill {letter.fill} / {letter.cells} dimension {letter.dimension.toFixed(4)}</span>321 ))}322 </Stats>323 <Stats>324 {word.error ? null : <span className={`chip ${shrinks ? 'proved' : 'refuted'}`}>{shrinks ? 'a letter buys a fraction under one, so the volume converges' : 'every letter fills its cell, so the volume grows without bound'}</span>}325 {word.error || dim === 6 ? null : <span className={`chip ${climbs ? 'proved' : 'conjecture'}`}>{climbs ? 'a letter under one and dimension over dim - 1, so the surface density diverges' : shrinks ? 'dimension at or under dim - 1, so the surface density stays bounded' : 'every letter full, so the surface density stays constant'}</span>}326 {dim === 6 && <span>the hexagon is the shadow of the cube tower: its volume is the inked share of the mesh and its surface the boundary edges of that ink over the mesh width</span>}327 {dim === 6 && q.proj === 'iso' && <span className="chip conjecture">the isometric skin is the visible surface, so it inks every triangle it draws and the volume sits at one; take the middle slice or the facing sides to watch it fall</span>}328 </Stats>329 <Sketch draw={drawChart} deps={[tower, dim]} className="bars" role="img" aria-label="Volume and surface running totals by block" />330 <Stats>331 <span><span className="swatch" style={{ background: ink.dim }}></span> volume per block, the fill fraction the census gives</span>332 <span><span className="swatch" style={{ background: ink.yellow }}></span> the volume running total, flattening</span>333 <span><span className="swatch" style={{ background: ink.blue }}></span> the surface running total, climbing</span>334 </Stats>335 <p className="sub">The bars are exported numbers, one fill fraction per block. The two curves are the running totals of those bars and of the exposed count over the side, each drawn against its own last value so the shapes can be read side by side: the volume bends over as its steps shrink by a constant factor, the surface bends up as its steps grow. Neither total is printed anywhere on the page, because a total is a sum and the sum would have to happen here rather than in Rust.</p>336 </Page>337 );338}339340mount(<App />);