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