index.jsx

10.9 kB · jsx · 219 lines

1import { useEffect, useMemo, useReducer, useRef, useState } from 'react';2import { ready } from '../../lib/mrly.js';3import { web } from '../../lib/chart.js';4import { web as solid } from '../../lib/stage.js';5import { mount, Page, Row, Pick, Slider, Btn, Stats, Stat, Note } from '../../lib/app.jsx';6import { Sketch } from '../../lib/draw.jsx';7import { Stage } from '../../lib/stage.jsx';8import { Picker, useSeeds, roll } from '../../lib/select.jsx';9import { useQuery } from '../../lib/query.js';1011const m = await ready();12const SPACES = [['flat', 'flat'], ['cube', 'cube'], ['hex', 'hex slice']];13const GRAPHS = [['core', 'core, filled cells'], ['edge', 'edge, corners and sides'], ['tunnel', 'tunnel, empty cells'], ['dual', 'dual, fills and voids']];14const KINDS = { flat: ['core', 'edge', 'tunnel'], cube: ['core', 'edge', 'tunnel'], hex: ['core', 'dual', 'edge'] };15const LAYOUTS = ['lattice', 'force'];16const CAMERAS = [['eye', 'perspective'], ['iso', 'isometric']];17const BUDGET = { lattice: 20000, force: 2000 };18const FIRST = { space: 'flat', camera: 'eye', code: '495', base: 3, ccode: '23', cbase: 2, number: 3, level: 2, graph: 'core', layout: 'lattice', dots: 3 };19const FIRST_TOP = 4;2021const rename = (patch) => {22  const next = {};23  if (patch.code !== undefined) next.ccode = patch.code;24  if (patch.base !== undefined) next.cbase = patch.base;25  return next;26};2728function App() {29  const s = useSeeds();30  const [pick, set] = useQuery(FIRST);31  const [playing, setPlaying] = useState(false);32  const [pulse, beat] = useReducer((x) => x + 1, 0);33  const [gen, again] = useReducer((x) => x + 1, 0);34  const st = useRef(null);35  const body = useRef(null);36  const speed = useRef(2);37  const opened = useRef(false);3839  const flat = pick.space === 'flat';40  const dimension = flat ? 2 : 3;41  const code = (flat ? pick.code : pick.ccode).trim();42  const base = flat ? pick.base : pick.cbase;43  const which = KINDS[pick.space].includes(pick.graph) ? pick.graph : 'core';44  const top = useMemo(() => {45    try {46      return m.graph_cap(pick.space, code, pick.number, base, which, BUDGET[pick.layout]);47    } catch {48      return FIRST_TOP;49    }50  }, [pick.space, code, pick.number, base, which, pick.layout]);51  const level = Math.min(pick.level, top);5253  const built = useMemo(() => {54    try {55      const name = m.name_of(code, dimension, base);56      const nodes = m.graph_nodes(pick.space, code, pick.number, level, base, which);57      const net = {58        dim: nodes[0],59        nodes,60        branches: m.graph_branches(pick.space, code, pick.number, level, base, which),61        roles: m.graph_roles(pick.space, code, pick.number, level, base, which),62      };63      const tally = JSON.parse(m.graph_census(pick.space, code, pick.number, level, base, which));64      const relax = pick.layout === 'force' ? new m.Layout(net.nodes.subarray(2), net.branches, net.dim, s.get()) : null;65      return { net, tally, name, relax, error: null };66    } catch (error) {67      return { net: null, tally: null, name: '', relax: null, error };68    }69  }, [pick.space, code, pick.number, base, which, level, pick.layout, pick.camera, gen]);7071  const nodes = () => (built.relax ? built.relax.positions() : built.net.nodes.subarray(2));7273  useEffect(() => {74    setPlaying(!!built.relax);75  }, [built]);7677  useEffect(() => {78    if (!playing || !built.relax) return;79    let id = 0;80    const frame = () => {81      const t0 = performance.now();82      built.relax.step(speed.current);83      const dt = performance.now() - t0;84      if (dt < 8 && speed.current < 8) speed.current += 1;85      else if (dt > 14 && speed.current > 1) speed.current -= 1;86      beat();87      id = requestAnimationFrame(frame);88    };89    id = requestAnimationFrame(frame);90    return () => cancelAnimationFrame(id);91  }, [playing, built]);9293  useEffect(() => {94    const seed = s.get();95    if (!seed) return;96    const [lv, g, ly] = roll(seed, [[1, FIRST_TOP], [0, GRAPHS.length - 1], [0, 1]]);97    const drawn = m.random_code(dimension, base, seed);98    set({ ...(flat ? { code: drawn } : { ccode: drawn }), level: lv, graph: GRAPHS[g][0], layout: LAYOUTS[ly] });99  }, []);100101  const randomize = () => {102    const seed = s.next();103    const [lv, g, ly] = roll(seed, [[1, top], [0, GRAPHS.length - 1], [0, 1]]);104    const drawn = m.random_code(dimension, base, seed);105    set({ ...(flat ? { code: drawn } : { ccode: drawn }), level: lv, graph: GRAPHS[g][0], layout: LAYOUTS[ly] });106  };107108  const sheet = (canvas) => {109    if (!built.net || pick.space === 'cube') return;110    web(canvas, Math.min(canvas.clientWidth, 620), nodes(), built.net.branches, built.net.roles, pick.dots);111  };112113  const onStage = (live) => {114    st.current = live;115    live.project(pick.camera);116    if (!opened.current) {117      opened.current = true;118      if (new URLSearchParams(location.search).get('camera') === 'iso') live.view(1, 1, 1);119    }120    if (!built.net) {121      live.clear();122      body.current = null;123      return;124    }125    if (pick.space !== 'cube') return;126    if (body.current && body.current.of === built && body.current.dots === pick.dots) {127      body.current.place(nodes());128      return;129    }130    body.current = { of: built, dots: pick.dots, ...solid(nodes(), built.net.branches, built.net.roles, pick.dots / 250) };131    live.clear();132    for (const part of body.current.parts) live.add(part);133  };134135  const tally = built.tally;136  const ticks = built.relax ? built.relax.ticks() : 0;137138  const controls = (139    <>140      <section>141        <h3>The design</h3>142        <Row>143          <Pick label="space" value={pick.space} options={SPACES} onChange={(v) => set({ space: v, graph: KINDS[v].includes(pick.graph) ? pick.graph : 'core' })} />144          <span className="set">145            <Picker dimension={dimension} bases={flat ? [3, 2] : [2, 3]} code={flat ? pick.code : pick.ccode} base={base} seeds={s} button={false}146              onChange={(patch) => set(flat ? patch : rename(patch))} />147            <Btn onClick={randomize}>Randomize</Btn>148          </span>149          <Pick label="number" value={pick.number} options={[[3, 3], [5, 5], [7, 7]]} onChange={(v) => set({ number: +v })} />150          <Slider label="level" value={level} min={1} max={top} show={`${level} of ${top}`} onChange={(v) => set({ level: v })} />151          <Pick label="graph" value={which} options={GRAPHS.filter(([value]) => KINDS[pick.space].includes(value))} onChange={(v) => set({ graph: v })} />152        </Row>153      </section>154      <section>155        <h3>The layout</h3>156        <Row>157          <Pick label="layout" value={pick.layout} options={LAYOUTS} onChange={(v) => set({ layout: v })} />158          <span className="tabs">159            <button disabled={!built.relax} onClick={() => setPlaying(!playing)}>{playing ? 'Pause' : 'Relax'}</button>160            <button disabled={!built.relax} onClick={again}>Reset</button>161          </span>162          <Slider label="dots" value={pick.dots} min={1} max={12} onChange={(v) => set({ dots: v })} />163        </Row>164      </section>165      <section hidden={pick.space !== 'cube'}>166        <h3>The camera</h3>167        <Row>168          <Pick label="camera" value={pick.camera} options={CAMERAS} onChange={(v) => set({ camera: v })} />169          <span className="tabs">170            <button onClick={() => st.current?.view(1, 1, 1)}>corner</button>171            <button onClick={() => st.current?.view(1, 1, 0)}>edge</button>172            <button onClick={() => st.current?.view(0, 0, 1)}>face</button>173          </span>174        </Row>175      </section>176    </>177  );178179  return (180    <Page crumb="graphs" title="The network of a design"181      sub="A design is dots. Join every filled cell to its neighbours and you have a network: tips, junctions and pieces you can count, a length you can add up, a box dimension you can read. See it flat, orbit it as a cube, or take the hexagon a cube's diagonal cut leaves, then let the dots push apart and watch the lattice relax into a shape."182      controls={controls}183      foot={<>The core graph joins face-adjacent filled cells at their centres; the edge graph is the corners and unit sides those cells outline; the tunnel graph joins the empty cells instead. On the hexagon the core graph joins filled triangles across shared sides, the dual takes fills and voids together, and the edge graph is their sides, every triangle at its true aspect. Every node, branch, role and count comes out of the crates, and the Euler number is the design's own, read off its cell complex, so the level slider stops where the closed-form size says a build would stall. The force layout is Fruchterman and Reingold's: every pair repels as <code>k²/d</code>, every branch pulls as <code>d²/k</code>, a cooling cap on the move per tick settles the lattice, and the seed jitters the start so a symmetric lattice can fold; energy is the mean net force per node in units of <code>k</code>. The same graphs diagonalised are the <a href="../spectra">spectra</a> page; the pieces and boundary of a design raced against a random set of the same mass are the <a href="/research/connectivity/">connectivity</a> page.</>}>184      <div className="arena" style={{ gridTemplateColumns: '1fr' }}>185        <div className="panel">186          <h2>The network <span>{`${which} graph, level ${level}, ${pick.layout}${pick.space === 'cube' ? `, ${pick.camera === 'iso' ? 'isometric' : 'perspective'}` : ''}`}</span></h2>187          <Sketch draw={sheet} deps={[built, pick.dots, pick.space, pulse]} hidden={pick.space === 'cube'} role="img" aria-label="The network" />188          <Stage onStage={onStage} deps={[built, pick.dots, pick.space, pick.camera, pulse]} hidden={pick.space !== 'cube'} role="img" aria-label="The network in the cube" />189          <Stats>190            <span><i className="swatch" style={{ background: 'var(--yellow)' }}></i> tip</span>191            <span><i className="swatch" style={{ background: 'var(--blue)' }}></i> path</span>192            <span><i className="swatch" style={{ background: 'var(--pink)' }}></i> junction</span>193            <span><i className="swatch" style={{ background: 'var(--dim)' }}></i> alone</span>194          </Stats>195        </div>196      </div>197      <Stats>198        <Stat label="name">{built.name}</Stat>199        <Stat label="nodes">{tally?.nodes}</Stat>200        <Stat label="branches">{tally?.branches}</Stat>201        <Stat label="tips">{tally?.tips}</Stat>202        <Stat label="junctions">{tally?.junctions}</Stat>203        <Stat label="pieces">{tally?.components}</Stat>204        <Stat label="length">{tally?.length.toFixed(2)}</Stat>205        <Stat label="box dimension">{tally?.box.toFixed(3)}</Stat>206        <Stat label="euler">{tally ? tally.euler ?? 'none' : ''}</Stat>207      </Stats>208      <Stats>209        <Stat label="ticks">{built.relax ? ticks : ''}</Stat>210        <Stat label="energy">{built.relax && ticks ? built.relax.energy().toExponential(2) : ''}</Stat>211        <Stat label="moved">{built.relax && ticks ? built.relax.moved().toExponential(2) : ''}</Stat>212        <Stat label="heat">{built.relax ? built.relax.temperature().toExponential(2) : ''}</Stat>213      </Stats>214      <Note error={built.error} />215    </Page>216  );217}218219mount(<App />);