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