index.jsx

27.1 kB · jsx · 523 lines

1import { useEffect, useRef, useState } from 'react';2import { ready, ink } from '../../lib/mrly.js';3import { board, line, axis, tag } from '../../lib/chart.js';4import { faces } from '../../lib/stage.js';5import { mount, Page, Row, Pick, Slider, Check, Btn, Stat, Note } from '../../lib/app.jsx';6import { Grid, Pixels, Sketch } from '../../lib/draw.jsx';7import { Stage } from '../../lib/stage.jsx';8import { Picker, useSeeds, roll } from '../../lib/select.jsx';9import { useQuery, stamp, share } from '../../lib/query.js';1011const m = await ready();12const MAX_SLOTS = 6;13const PLANE = 243;14const SOLID = 128;15const CUBES = 150000;1617const WORDS = {18  doctest: { view: 'plane', compare: 'swap', chart: 'exponent', letters: [['7', 2, 3], ['14', 2, 7], ['9', 2, 5]] },19  periodic: { view: 'plane', compare: 'double', chart: 'exponent', letters: [['7', 2, 3], ['9', 2, 5]] },20  staircase: { view: 'nest', compare: 'double', chart: 'stair', letters: [['7', 2, 3], ['7', 2, 3], ['7', 2, 5], ['7', 2, 3], ['7', 2, 5], ['7', 2, 7]] },21  order: { view: 'plane', compare: 'swap', chart: 'exponent', letters: [['3', 2, 2], ['6', 2, 2]] },22  morse: { view: 'nest', compare: 'swap', chart: 'exponent', letters: [['3', 2, 2], ['7', 2, 2], ['7', 2, 2], ['3', 2, 2], ['7', 2, 2], ['3', 2, 2]] },23  collide: { view: 'plane', compare: 'collide', chart: 'exponent', letters: [['9', 2, 2], ['273', 3, 3]] },24  sponge: { view: 'solid', compare: 'swap', chart: 'stair', letters: [['23', 2, 3], ['9', 2, 3]] },25};2627const NAMES = [28  ['doctest', 'the constructor doctest word'],29  ['periodic', 'a pair, and the same pair doubled'],30  ['staircase', 'the staircase, three blocks'],31  ['order', 'the minimal order pair, swapped'],32  ['morse', 'Thue-Morse over the gasket and the domino'],33  ['collide', 'one tile, two words'],34  ['sponge', 'a solid word'],35];3637const COLLISION = [38  { codes: ['9', '273'], numbers: [2, 3], bases: [2, 3] },39  { codes: ['273', '9'], numbers: [3, 2], bases: [3, 2] },40];4142const SAYS = {43  double: 'Block reduction: a word repeated is not a word, it is a letter. The doubled word is the ordinary self-similar theory of its one-period composite, at side the product of the sides and fill the product of the fills, so the side and the fill square and the dimension does not move. Proved, research/magic.md.',44  swap: 'Order is the object. Side, fill, density and the main-diagonal count are functions of the letter multiset alone, so a swap moves none of them; components, Euler characteristic, holes and boundary are order-sensitive, so a swap moves the piece count. Proved, research/magic.md and research/connectivity.md.',45  collide: 'Factorisation in the tile monoid is not unique. I(2) x I(3) and I(3) x I(2) are the same side-6 tile spelt by two different words, and the identity (nm - 1) - x = (n - 1 - i) m + (m - 1 - j) is symmetric in the two sides, so the collision happens at every pair of sides. Both sides here are prime, so all four letters are irreducible. Proved, cited rather than claimed, research/magic.md. A base-3 code whose digit set is not a parity rule is a tile and not a design, and inherits nothing from the design census.',46};4748const CHARTS = {49  exponent:50    'Over the 15 plane codes at side two, where both letters occur with strictly positive frequency, the component exponent exists, depends on the letter frequencies alone, and equals the fill exponent on 104 of the 105 letter pairs; the periodic control at the same frequencies lands on the same limit, so a difference against the prediction refutes the prediction and not stationarity. The constant-word functional Phi(f) = (f_6 + f_9) log 2 is then refuted on 78 of the 105 letter pairs and exact on 27. Proved, research/connectivity.md. The interior-frequency hypothesis rides inside the statement: at a boundary frequency three words over one pair give rates 0, log 2 and no limit at all.',51  outside:52    'These two letters lie outside the alphabet the closed forms cover, the 15 plane codes at side two, so the curves below are exact counts of the drawn word and no rate is claimed for them. Switch to a pair of side-2 letters, or to the Thue-Morse preset, for the proved reading. Research/connectivity.md.',53  stair:54    'The staircase stacks prefixes, so the letter in place j occurs n - j + 1 times in the first n blocks and the dimension is the occurrence-weighted average of the per-letter dimensions. It is not monotone: it dips at the second block because the base-5 carpet is less dense than the base-3 carpet, then climbs, and its limit is the ambient dimension. Proved, research/magic.md; the five printed values are Verified, lab/py/slice-ladder-controls.',55};5657const FIRST = { view: 'plane', compare: 'swap', chart: 'exponent', schedule: 'thue-morse', length: 64, blocks: 5 };5859const attempt = (fn) => {60  try {61    return { ...fn(), error: null };62  } catch (error) {63    return { error };64  }65};6667const side = (value) => Math.min(16, Math.max(2, +value || 2));6869function token(dimension, codes, numbers, bases) {70  try {71    return m.magic_name(codes, numbers, bases, dimension);72  } catch {73    return '';74  }75}7677function keyed(dimension, codes, numbers, bases) {78  try {79    return m.magic_key(codes, numbers, bases, dimension);80  } catch {81    return '';82  }83}8485function repeats(dimension, codes, numbers, bases) {86  const seen = new Map();87  for (let i = 0; i < codes.length; i++) {88    const key = `${m.name_of(codes[i], dimension, bases[i])} at side ${numbers[i]}`;89    seen.set(key, (seen.get(key) ?? 0) + 1);90  }91  const doubled = [...seen].filter(([, count]) => count > 1);92  if (!doubled.length) return '';93  return `There is no level control: the word length is the depth, and a repeated letter is how a level is spelt. This word repeats ${doubled.map(([key, count]) => `${key} ${count} times`).join(', ')}.`;94}9596function flags(census) {97  return [98    census.constant && <span key="constant" className="chip proved">constant</span>,99    census.periodic && <span key="periodic" className="chip proved">periodic</span>,100    census.composite && <span key="composite" className="chip proved">composite at base {census.residue_base}</span>,101    census.native && <span key="native" className="chip verified">native</span>,102  ].filter(Boolean);103}104105function nesting(codes, numbers, bases, taken) {106  const full = m.magic_grid(codes.slice(0, taken), numbers.slice(0, taken), bases.slice(0, taken));107  const size = full.width;108  const field = new Float32Array(size * size);109  for (let depth = 1; depth <= taken; depth++) {110    const grid =111      depth === 1112        ? m.two_grid(codes[0], numbers[0], 1, 0, bases[0])113        : m.magic_grid(codes.slice(0, depth), numbers.slice(0, depth), bases.slice(0, depth));114    const step = size / grid.width;115    for (let r = 0; r < size; r++) {116      const row = Math.floor(r / step) * grid.width;117      for (let c = 0; c < size; c++) {118        if (grid.types[row + Math.floor(c / step)]) field[r * size + c] += 1;119      }120    }121  }122  return m.sheet(field, size, 'fire', taken, false);123}124125function tile(word, budget) {126  const taken = m.magic_cap(word.numbers, word.dimension, budget);127  if (taken < 2) throw new Error('the first two letters already pass the page budget; lower a side.');128  const grid = m.magic_grid(word.codes.slice(0, taken), word.numbers.slice(0, taken), word.bases.slice(0, taken));129  const census = JSON.parse(m.magic_census(word.codes, word.numbers, word.dimension, word.bases));130  return { word, taken, grid, census, name: token(word.dimension, word.codes, word.numbers, word.bases) };131}132133function firstSlots(params, dimension) {134  const list = [];135  if (params.has('w')) {136    try {137      const read = JSON.parse(m.magic_parse(params.get('w')));138      for (const [i, code] of read.codes.entries()) list.push({ code, base: read.bases[i], number: read.numbers[i] });139      for (let i = 0; i < MAX_SLOTS; i++) stamp({ [`l${i}code`]: null, [`l${i}base`]: null, [`l${i}n`]: null });140    } catch {141      list.length = 0;142    }143  } else {144    for (let i = 0; i < MAX_SLOTS; i++) {145      if (!params.has(`l${i}code`)) break;146      list.push({ code: params.get(`l${i}code`), base: +(params.get(`l${i}base`) ?? 2), number: +(params.get(`l${i}n`) ?? 3) });147    }148  }149  if (list.length >= 2) return list;150  return WORDS[dimension === 3 ? 'sponge' : 'doctest'].letters.map(([code, base, number]) => ({ code, base, number }));151}152153function App() {154  const shared = useSeeds();155  const [pick, set] = useQuery(FIRST);156  const [slots, setSlots] = useState(() => firstSlots(new URLSearchParams(location.search), pick.view === 'solid' ? 3 : 2));157  const [preset, setPreset] = useState('doctest');158  const [art, setArt] = useState(false);159  const sheetRef = useRef(null);160  const [spin, setSpin] = useState(true);161  const [iso, setIso] = useState(false);162  const [probe, setProbe] = useState('4');163  const [shout, setShout] = useState(null);164165  const dimension = pick.view === 'solid' ? 3 : 2;166  const codes = slots.map((slot) => slot.code.trim());167  const numbers = slots.map((slot) => side(slot.number));168  const bases = slots.map((slot) => (dimension === 2 ? slot.base : 2));169  const name = token(dimension, codes, numbers, bases);170  const key = keyed(dimension, codes, numbers, bases);171  const sig = `${pick.view}|${JSON.stringify(slots)}`;172173  useEffect(() => {174    const values = { w: key || null };175    for (let i = 0; i < MAX_SLOTS; i++) {176      values[`l${i}code`] = i < slots.length ? codes[i] : null;177      values[`l${i}base`] = i < slots.length && dimension === 2 ? bases[i] : null;178      values[`l${i}n`] = i < slots.length ? numbers[i] : null;179    }180    stamp(values);181  }, [sig]);182183  useEffect(() => {184    setShout(null);185  }, [sig, pick.compare, pick.chart, pick.schedule, pick.length, pick.blocks, art, spin, iso, probe]);186187  const budget = dimension === 3 ? SOLID : PLANE;188  const word = attempt(() => {189    const taken = m.magic_cap(numbers, dimension, budget);190    if (taken < 2) throw new Error(`the first two letters already pass side ${budget}; lower a side.`);191    return { taken, census: JSON.parse(m.magic_census(codes, numbers, dimension, bases)) };192  });193  const cut = (list) => list.slice(0, word.taken);194  const shape =195    pick.view === 'solid' && !word.error196      ? attempt(() => {197        const count = Number(m.word_count(cut(codes), cut(numbers), 3, cut(bases)));198        if (count > CUBES) throw new Error(`${count} cubes is more than this page draws; drop a letter or lower a side.`);199        return { buffer: m.magic_faces(cut(codes), cut(numbers), cut(bases)) };200      })201      : { buffer: null, error: null };202203  const pair = attempt(() => {204    if (dimension !== 2 && pick.compare !== 'collide') throw new Error('the pair panel is a plane reading; switch the view off solid.');205    const plain = { codes, numbers, bases, dimension };206    let left = plain;207    let right = plain;208    if (pick.compare === 'double') {209      right = { codes: codes.concat(codes), numbers: numbers.concat(numbers), bases: bases.concat(bases), dimension };210    } else if (pick.compare === 'swap') {211      const order = (list) => [list[1], list[0], ...list.slice(2)];212      right = { codes: order(codes), numbers: order(numbers), bases: order(bases), dimension };213    } else {214      left = { ...COLLISION[0], dimension: 2 };215      right = { ...COLLISION[1], dimension: 2 };216    }217    const a = tile(left, PLANE);218    const b = tile(right, PLANE);219    const same = a.grid.width === b.grid.width && a.grid.types.every((byte, i) => byte === b.grid.types[i]);220    const square = (text) => (BigInt(text) * BigInt(text)).toString();221    const reads = [];222    if (pick.compare === 'double') {223      reads.push(['side squares', b.census.side === square(a.census.side)]);224      reads.push(['fill squares', b.census.fill === square(a.census.fill)]);225      reads.push(['dimension unmoved', Math.abs(a.census.dimension - b.census.dimension) < 1e-12]);226    } else if (pick.compare === 'swap') {227      reads.push(['side equal', a.census.side === b.census.side]);228      reads.push(['fill equal', a.census.fill === b.census.fill]);229      reads.push(['pieces equal', a.census.components === b.census.components]);230    } else {231      reads.push(['same tile', same]);232      reads.push(['same word', false]);233    }234    return { a, b, reads };235  });236237  const chart = attempt(() => {238    if (pick.chart === 'stair') {239      const read = JSON.parse(m.magic_staircase(pick.blocks));240      const rows = read.rows.map((row) => row.dimension);241      const at = (i) => (rows.length > 1 ? i / (rows.length - 1) : 0);242      const low = Math.min(read.constant, ...rows);243      const high = Math.max(read.constant, ...rows);244      const pad = (high - low) * 0.25 || 0.001;245      const options = {246        low: low - pad,247        high: high + pad,248        marks: [[read.constant, ink.blue, `the constant word ${read.constant.toFixed(9)}`]],249        lines: [[rows.map((value, i) => [at(i), value]), ink.yellow, 1.8]],250        labels: [[0, 'one block'], [1, `${pick.blocks} blocks`]],251      };252      const stats = (253        <>254          <span><span className="swatch" style={{ background: ink.yellow }}></span> staircase dimension</span>255          {read.rows.map((row) => <span key={row.blocks}>{row.blocks} blocks, {row.length} letters <b>{row.dimension.toFixed(9)}</b></span>)}256          <span>dips at the second block <b>{String(read.rows[1].dimension < read.rows[0].dimension)}</b></span>257        </>258      );259      return { options, stats, say: CHARTS.stair };260    }261    if (dimension !== 2) throw new Error('the component exponent is a plane reading; switch the view off solid.');262    const read = JSON.parse(m.magic_rates(codes, numbers, bases, pick.schedule, pick.length));263    const total = read.length;264    const at = (i) => (total > 1 ? i / (total - 1) : 0);265    const component = read.rows.map((row, i) => [at(i), row[0]]);266    const fill = read.rows.map((row, i) => [at(i), row[1]]);267    const control = read.control.map((value, i) => [at(i), value]);268    const values = read.rows.flat().concat(read.control, [read.phi, read.limit]);269    const options = {270      low: Math.min(0, ...values),271      high: Math.max(...values) * 1.08,272      marks: [273        [read.limit, ink.green, `interior exponent ${read.limit.toFixed(9)}`],274        [read.phi, ink.pink, `Phi(f) ${read.phi.toFixed(4)}`],275      ],276      lines: [[fill, ink.blue, 1], [control, ink.orange, 1.2], [component, ink.yellow, 1.8]],277      labels: [[0, 'length 1'], [1, `length ${total}`]],278    };279    const last = read.rows[total - 1];280    const stats = (281      <>282        <span><span className="swatch" style={{ background: ink.yellow }}></span> {read.schedule} component rate <b>{last[0].toFixed(9)}</b></span>283        <span><span className="swatch" style={{ background: ink.orange }}></span> periodic control <b>{read.control[total - 1].toFixed(9)}</b></span>284        <span><span className="swatch" style={{ background: ink.blue }}></span> fill rate <b>{last[1].toFixed(9)}</b></span>285        <span><span className="swatch" style={{ background: ink.green }}></span> interior exponent <b>{read.limit.toFixed(15)}</b></span>286        <span><span className="swatch" style={{ background: ink.pink }}></span> Phi(f) <b>{read.phi.toFixed(9)}</b></span>287        <span>letters <b>{read.letters.join(' and ')}</b></span>288        <span>lengths <b>1 to {total}</b></span>289        <span className={`chip ${read.alphabet ? 'proved' : 'conjecture'}`}>{read.alphabet ? 'inside the closed-form alphabet' : 'outside the closed-form alphabet'}</span>290      </>291    );292    return { options, stats, say: read.alphabet ? CHARTS.exponent : CHARTS.outside };293  });294295  const patch = (i, values) => setSlots(slots.map((slot, k) => (k === i ? { ...slot, ...values } : slot)));296297  const load = (key) => {298    setPreset(key);299    setSlots(WORDS[key].letters.map(([code, base, number]) => ({ code, base, number })));300    set({ view: WORDS[key].view, compare: WORDS[key].compare, chart: WORDS[key].chart, ...(key === 'morse' ? { schedule: 'thue-morse' } : {}) });301  };302303  const randomize = () => {304    const seed = shared.next();305    const drawn = m.random_codes(dimension, 2, seed, slots.length);306    const sides = roll(seed, slots.map(() => [2, 16]));307    setSlots(slots.map((slot, i) => ({ code: drawn[i], base: 2, number: sides[i] })));308  };309310  const swap = (i) => {311    const next = [...slots];312    const at = (i + 1) % next.length;313    [next[i], next[at]] = [next[at], next[i]];314    setSlots(next);315  };316317  const png = () => {318    if (pick.view === 'solid') {319      setShout(new Error('the solid view saves no picture, and nothing in the crates writes OBJ.'));320      return;321    }322    const canvas = sheetRef.current;323    if (!canvas) return;324    const link = document.createElement('a');325    link.download = `${key || 'word'}.png`;326    link.href = canvas.toDataURL('image/png');327    link.click();328  };329330  const member = () => {331    const text = probe.trim();332    if (!text) return '';333    try {334      return String(m.word_member(codes, numbers, dimension, bases, text));335    } catch (error) {336      return String(error.message ?? error);337    }338  };339340  const sheet = () => {341    if (word.error || pick.view === 'solid') return null;342    if (pick.view === 'nest') return <Pixels data={nesting(codes, numbers, bases, word.taken)} canvasRef={sheetRef} role="img" aria-label="the word" />;343    return <Grid grid={m.magic_grid(cut(codes), cut(numbers), cut(bases))} on={ink.yellow} canvasRef={sheetRef} role="img" aria-label="the word" />;344  };345346  const onStage = (live) => {347    if (pick.view !== 'solid' || !shape.buffer) {348      live.clear();349      return;350    }351    live.show(faces(shape.buffer, ink.blue, 1));352    live.project(iso ? 'iso' : 'eye');353    live.spin = spin ? 0.004 : 0;354  };355356  const drawChart = (canvas) => {357    if (!chart.options) return;358    const b = board(canvas, 240, { left: 46, right: 16 });359    const { low, high, marks, lines, labels } = chart.options;360    const span = high - low || 1;361    const at = (value) => (value - low) / span;362    axis(b, labels, { wall: true });363    for (const [value, color, text] of marks) {364      line(b, [[0, at(value)], [1, at(value)]], color, { width: 1, dash: [4, 4] });365      tag(b, text, color, 'right', b.x(1), b.y(at(value)) - 4);366    }367    for (const [points, color, width] of lines) {368      line(b, points.map(([x, y]) => [x, at(y)]), color, { width, dots: points.length < 12 ? 3 : 0 });369    }370    b.ctx.fillStyle = ink.dim;371    b.ctx.fillText(high.toFixed(3), 2, b.roof + 4);372    b.ctx.fillText(low.toFixed(3), 2, b.floor);373  };374375  const readout = () => {376    const census = word.census;377    const members = m.word_count(codes, numbers, dimension, bases);378    const agrees = members === census.fill;379    let profile = [];380    try {381      profile = m.word_profile(codes, numbers, dimension, bases).map(Number);382    } catch {383      profile = [];384    }385    const peak = profile.length ? Math.max(...profile) : 0;386    const head = Number(members) <= 4096 ? m.word_members(codes, numbers, dimension, bases).slice(0, 6).join(', ') : '';387    return (388      <>389        {name && <Stat label="name">{name}</Stat>}390        <Stat label="side">{census.side}</Stat>391        <Stat label="cells">{census.cells}</Stat>392        <Stat label="filled">{census.fill}</Stat>393        <Stat label="empty">{census.voids}</Stat>394        <Stat label="density">{census.ratio.toFixed(4)}</Stat>395        <Stat label="dimension">{census.dimension.toFixed(4)}</Stat>396        {census.components ? <span>pieces <b>{census.components}</b> <span className="dim">{census.counted}</span></span> : null}397        <span>press members <b>{String(members)}</b> <span className={`chip ${agrees ? 'verified' : 'refuted'}`}>{agrees ? 'agrees with the fill' : 'differs from the fill'}</span></span>398        {profile.length ? <span>diagonal profile <b>{profile.length}</b> heights, peak <b>{peak}</b> at <b>{profile.indexOf(peak)}</b></span> : null}399        {head ? <Stat label="first members">{head}</Stat> : null}400        {flags(census)}401      </>402    );403  };404405  const scale = () => {406    const census = word.census;407    const drawing = word.taken < codes.length408      ? <>Drawing <b>{word.taken} of {codes.length} letters</b>, the box cover of the whole word at side {census.letters.slice(0, word.taken).reduce((a, l) => a * l.number, 1)}. </>409      : null;410    return <>{drawing}The readout is a product over the letters, so it outruns the raster on purpose: every number above is exact at the full length. {repeats(dimension, codes, numbers, bases)} <a href="../moire">Moire</a> stacks one design over its scales instead.</>;411  };412413  const pane = (one) => (414    <div>415      <Grid grid={one.grid} on={ink.yellow} role="img" aria-label={one.name || 'the word'} />416      <div className="stats">417        <span>{one.name}</span>418        <Stat label="side">{one.census.side}</Stat>419        <Stat label="filled">{one.census.fill}</Stat>420        <Stat label="dimension">{one.census.dimension.toFixed(4)}</Stat>421        {one.census.components ? <Stat label="pieces">{one.census.components}</Stat> : null}422        {one.taken < one.word.codes.length ? <span className="dim">drawn {one.taken} of {one.word.codes.length} letters</span> : null}423      </div>424    </div>425  );426427  const picture = (428    <section key="picture">429      <h3>The picture</h3>430      <Row>431        <Check label="art" checked={art} onChange={setArt} />432        <Btn onClick={png}>{pick.view === 'solid' ? 'PNG (plane only)' : 'PNG'}</Btn>433      </Row>434    </section>435  );436437  const controls = art ? picture : (438    <>439      <section>440        <h3>The word</h3>441        <Row>442          <Pick label="preset" value={preset} options={NAMES} onChange={load} />443          <Btn onClick={randomize}>Randomize</Btn>444          <Pick label="view" value={pick.view} options={[['plane', 'plane'], ['nest', 'nesting'], ['solid', 'solid']]} onChange={(v) => set({ view: v })} />445          {pick.view === 'solid' && <Check label="spin" checked={spin} onChange={setSpin} />}446          {pick.view === 'solid' && <Check label="iso" checked={iso} onChange={setIso} />}447        </Row>448      </section>449      <section>450        <h3>The letters</h3>451        {slots.map((slot, i) => (452          <Row key={i}>453            <span className="badge">letter {i + 1}</span>454            <span className="set">455              <Picker dimension={dimension} bases={dimension === 2 ? [2, 3] : [2]} code={slot.code} base={bases[i]} seeds={shared} button={false} onChange={(values) => patch(i, values)} />456            </span>457            <label>side <input type="number" value={slot.number} min={2} max={16} onChange={(e) => patch(i, { number: e.target.value })} /></label>458            <button disabled={slots.length < 2} onClick={() => swap(i)}>swap next</button>459            <button disabled={slots.length < 3} onClick={() => setSlots(slots.filter((one, k) => k !== i))}>remove</button>460          </Row>461        ))}462        <Row>463          <button disabled={slots.length >= MAX_SLOTS} onClick={() => setSlots([...slots, { ...slots[slots.length - 1] }])}>add a letter</button>464          <label>is <input type="text" value={probe} onChange={(e) => setProbe(e.target.value)} /> a member <b className="num">{member()}</b></label>465          <span className="badge dim">{name}</span>466        </Row>467      </section>468      <section>469        <h3>The pair</h3>470        <Row>471          <Pick label="compare" value={pick.compare} onChange={(v) => set({ compare: v })}472            options={[['swap', 'the first two letters swapped'], ['double', 'the same word doubled'], ['collide', 'I(2) x I(3) beside I(3) x I(2)']]} />473        </Row>474      </section>475      <section>476        <h3>The chart</h3>477        <Row>478          <Pick label="chart" value={pick.chart} options={[['exponent', 'the component exponent'], ['stair', 'the staircase dimension']]} onChange={(v) => set({ chart: v })} />479          {pick.chart === 'exponent' && <Pick label="schedule" value={pick.schedule} options={[['thue-morse', 'Thue-Morse'], ['periodic', 'periodic'], ['constant', 'constant']]} onChange={(v) => set({ schedule: v })} />}480          {pick.chart === 'exponent' && <Slider label="length" value={pick.length} min={2} max={98} onChange={(v) => set({ length: v })} />}481          {pick.chart === 'stair' && <Slider label="blocks" value={pick.blocks} min={2} max={8} onChange={(v) => set({ blocks: v })} />}482        </Row>483      </section>484      {picture}485    </>486  );487488  return (489    <Page bare={art} crumb="words" title="One design per level, and the order of the letters counts"490      sub="One design per level. Same letters in a different order, a different set. Build a word letter by letter, first letter outermost, and every number below comes back out of the crates as a product over the letters."491      controls={controls}492      foot={<>Side, fill, density and dimension are products over the letters, so the readout is exact at any length even where the raster is not: the plane draws to side 243 and the cube to side 128, and a shorter render is labelled with the count of letters it drew because it is the box cover of the whole word at that scale and never a shallower word. Words with two or more letters only; a word of one repeated letter is the ordinary fractal under another name. Links: <a href="../sponge">the sponge</a> grows one cube design level by level, <a href="../moire">moire</a> stacks one design over its scales. The grammar of a word, the families that collapse back into one fractal and the ones that do not are in <a href="/research/magic/">the magic words note</a>, and what a swap costs is the paper <a href="/papers/order-sensitivity-of-kronecker-words/">order sensitivity of Kronecker words</a>.</>}>493      <div className="arena">494        <div className="panel">495          <h2>the word <span>{name}</span></h2>496          {sheet()}497          <Stage onStage={onStage} deps={[pick.view, shape.buffer, iso, spin]} hidden={pick.view !== 'solid'} role="img" aria-label="the word in the cube" />498          <div className="stats" hidden={art}>{word.error ? null : readout()}</div>499          <div className="stats" hidden={art}>{word.error ? null : word.census.letters.map((letter, i) => (500            <span key={i} className="badge">{i + 1} <b>{letter.name}</b> side {letter.number} fill {letter.fill} dimension {letter.dimension.toFixed(4)}{letter.native ? ' native' : ''}</span>501          ))}</div>502          <Note error={shout ?? word.error ?? shape.error} />503          <p className="sub" hidden={art}>{word.error ? null : scale()}</p>504          <p className="sub">The first letter alone grows level by level in <a href={`../sponge${share({ code: codes[0], base: bases[0], number: numbers[0], level: 3 })}`}>the sponge</a>.</p>505        </div>506        <div className="panel" hidden={art}>507          <h2>the pair <span>{pick.compare === 'collide' ? 'one tile, two words' : `the word beside ${pick.compare === 'double' ? 'itself doubled' : 'its swap'}`}</span></h2>508          <div className="arena">509            {pair.error ? null : pane(pair.a)}510            {pair.error ? null : pane(pair.b)}511          </div>512          <Note error={pair.error}>{pair.error ? null : pair.reads.map(([text, value], i) => <span key={i}>{text} <b>{String(value)}</b>{' '}</span>)}</Note>513          <p className="sub">{SAYS[pick.compare]}</p>514        </div>515      </div>516      <Sketch draw={drawChart} deps={[chart]} className="bars" hidden={art} role="img" aria-label={pick.chart === 'stair' ? 'The staircase dimension' : 'The component exponent'} />517      <div className="stats" hidden={art}>{chart.error ? String(chart.error.message ?? chart.error) : chart.stats}</div>518      <p className="sub" hidden={art}>{chart.say ?? CHARTS[pick.chart]}</p>519    </Page>520  );521}522523mount(<App />);