T45b Site · Iso: isometric line miniatures on a canvas, hidden lines removed, cells on a marimba beat, playable under the pointer, still under reduced motion
This commit is contained in:
1 parent
ddcc8a4938
commit
02c2bbe0a1
13 files changed
+488
-15
No files matched your search
@@ -8,10 +8,12 @@
|
|||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test",
|
||||||
|
"test": "node --test src/iso/engine.test.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
|
"@types/node": "24.19.0",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
|||||||
Generated
+28
-11
@@ -33,6 +33,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.63.0
|
specifier: 1.63.0
|
||||||
version: 1.63.0
|
version: 1.63.0
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.19.0
|
||||||
|
version: 24.19.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 19.3.0
|
specifier: 19.3.0
|
||||||
version: 19.3.0
|
version: 19.3.0
|
||||||
@@ -41,16 +44,16 @@ importers:
|
|||||||
version: 19.3.0(@types/react@19.3.0)
|
version: 19.3.0(@types/react@19.3.0)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 6.1.1
|
specifier: 6.1.1
|
||||||
version: 6.1.1(vite@8.3.1(esbuild@0.28.1))
|
version: 6.1.1(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1))
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^7.0.2
|
specifier: ^7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.3.1
|
specifier: 8.3.1
|
||||||
version: 8.3.1(esbuild@0.28.1)
|
version: 8.3.1(@types/node@24.19.0)(esbuild@0.28.1)
|
||||||
wrangler:
|
wrangler:
|
||||||
specifier: 4.143.0
|
specifier: 4.143.0
|
||||||
version: 4.143.0
|
version: 4.143.0(@types/node@24.19.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -585,6 +588,9 @@ packages:
|
|||||||
'@tanstack/store@0.11.2':
|
'@tanstack/store@0.11.2':
|
||||||
resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==}
|
resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==}
|
||||||
|
|
||||||
|
'@types/node@24.19.0':
|
||||||
|
resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==}
|
||||||
|
|
||||||
'@types/react-dom@19.3.0':
|
'@types/react-dom@19.3.0':
|
||||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -964,6 +970,9 @@ packages:
|
|||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.24.6:
|
||||||
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
undici@7.29.0:
|
undici@7.29.0:
|
||||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -1379,6 +1388,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/store@0.11.2': {}
|
'@tanstack/store@0.11.2': {}
|
||||||
|
|
||||||
|
'@types/node@24.19.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.24.6
|
||||||
|
|
||||||
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
'@types/react-dom@19.3.0(@types/react@19.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
@@ -1447,10 +1460,10 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.1.1(vite@8.3.1(esbuild@0.28.1))':
|
'@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
vite: 8.3.1(esbuild@0.28.1)
|
vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.1)
|
||||||
|
|
||||||
blake3-wasm@2.1.5: {}
|
blake3-wasm@2.1.5: {}
|
||||||
|
|
||||||
@@ -1557,10 +1570,10 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.33.0
|
lightningcss-win32-arm64-msvc: 1.33.0
|
||||||
lightningcss-win32-x64-msvc: 1.33.0
|
lightningcss-win32-x64-msvc: 1.33.0
|
||||||
|
|
||||||
miniflare@5.20260926.0-alpha:
|
miniflare@5.20260926.0-alpha(@types/node@24.19.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cspotcode/source-map-support': 0.8.1
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
sharp: 0.35.4
|
sharp: 0.35.4(@types/node@24.19.0)
|
||||||
undici: 7.29.0
|
undici: 7.29.0
|
||||||
workerd: 1.20260926.1
|
workerd: 1.20260926.1
|
||||||
ws: 8.21.0
|
ws: 8.21.0
|
||||||
@@ -1632,7 +1645,7 @@ snapshots:
|
|||||||
|
|
||||||
seroval@1.6.8: {}
|
seroval@1.6.8: {}
|
||||||
|
|
||||||
sharp@0.35.4:
|
sharp@0.35.4(@types/node@24.19.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.1.0
|
'@img/colour': 1.1.0
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
@@ -1663,6 +1676,7 @@ snapshots:
|
|||||||
'@img/sharp-win32-arm64': 0.35.4
|
'@img/sharp-win32-arm64': 0.35.4
|
||||||
'@img/sharp-win32-ia32': 0.35.4
|
'@img/sharp-win32-ia32': 0.35.4
|
||||||
'@img/sharp-win32-x64': 0.35.4
|
'@img/sharp-win32-x64': 0.35.4
|
||||||
|
'@types/node': 24.19.0
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
@@ -1699,6 +1713,8 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-arm64': 7.0.2
|
'@typescript/typescript-win32-arm64': 7.0.2
|
||||||
'@typescript/typescript-win32-x64': 7.0.2
|
'@typescript/typescript-win32-x64': 7.0.2
|
||||||
|
|
||||||
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
undici@7.29.0: {}
|
undici@7.29.0: {}
|
||||||
|
|
||||||
unenv@2.0.0-rc.24:
|
unenv@2.0.0-rc.24:
|
||||||
@@ -1709,7 +1725,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
|
|
||||||
vite@8.3.1(esbuild@0.28.1):
|
vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
@@ -1717,6 +1733,7 @@ snapshots:
|
|||||||
rolldown: 1.2.11
|
rolldown: 1.2.11
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 24.19.0
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
@@ -1728,13 +1745,13 @@ snapshots:
|
|||||||
'@cloudflare/workerd-linux-arm64': 1.20260926.1
|
'@cloudflare/workerd-linux-arm64': 1.20260926.1
|
||||||
'@cloudflare/workerd-windows-64': 1.20260926.1
|
'@cloudflare/workerd-windows-64': 1.20260926.1
|
||||||
|
|
||||||
wrangler@4.143.0:
|
wrangler@4.143.0(@types/node@24.19.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/kv-asset-handler': 0.5.0
|
'@cloudflare/kv-asset-handler': 0.5.0
|
||||||
'@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260926.1)
|
'@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260926.1)
|
||||||
blake3-wasm: 2.1.5
|
blake3-wasm: 2.1.5
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
miniflare: 5.20260926.0-alpha
|
miniflare: 5.20260926.0-alpha(@types/node@24.19.0)
|
||||||
path-to-regexp: 6.3.0
|
path-to-regexp: 6.3.0
|
||||||
unenv: 2.0.0-rc.24
|
unenv: 2.0.0-rc.24
|
||||||
workerd: 1.20260926.1
|
workerd: 1.20260926.1
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link, Outlet } from "@tanstack/react-router";
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
import { REPO } from "./data";
|
import { REPO } from "./data";
|
||||||
import { Icon } from "./icons";
|
import { Icon } from "./icons";
|
||||||
|
import { setSound, useSound } from "./iso/marimba";
|
||||||
import { setMode, useMode } from "./theme";
|
import { setMode, useMode } from "./theme";
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
const theme = useMode();
|
const theme = useMode();
|
||||||
const dark = theme === "dark";
|
const dark = theme === "dark";
|
||||||
|
const sound = useSound();
|
||||||
return (
|
return (
|
||||||
<header className="bar">
|
<header className="bar">
|
||||||
<Link to="/" className="brand">
|
<Link to="/" className="brand">
|
||||||
@@ -17,6 +19,9 @@ function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="end">
|
<div className="end">
|
||||||
|
<button className="tool" aria-label={sound ? "Sound off" : "Sound on"} aria-pressed={sound} onClick={() => setSound(!sound)}>
|
||||||
|
<Icon name={sound ? "Sound" : "VolumeCross"} />
|
||||||
|
</button>
|
||||||
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
|
<button className="tool" aria-label={dark ? "Light mode" : "Dark mode"} onClick={() => setMode(dark ? "light" : "dark")}>
|
||||||
<Icon name={dark ? "Sun" : "Moon"} />
|
<Icon name={dark ? "Sun" : "Moon"} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import Maximize from "reicon/icons/Maximize";
|
|||||||
import Minimize from "reicon/icons/Minimize";
|
import Minimize from "reicon/icons/Minimize";
|
||||||
import Moon from "reicon/icons/Moon";
|
import Moon from "reicon/icons/Moon";
|
||||||
import Refresh from "reicon/icons/Refresh";
|
import Refresh from "reicon/icons/Refresh";
|
||||||
|
import Sound from "reicon/icons/Sound";
|
||||||
import Sun from "reicon/icons/Sun";
|
import Sun from "reicon/icons/Sun";
|
||||||
|
import VolumeCross from "reicon/icons/VolumeCross";
|
||||||
|
|
||||||
const set = { ArrowRightUp, Maximize, Minimize, Moon, Refresh, Sun };
|
const set = { ArrowRightUp, Maximize, Minimize, Moon, Refresh, Sound, Sun, VolumeCross };
|
||||||
|
|
||||||
// simple-icons' GitHub mark (CC0); Reicon has none.
|
// simple-icons' GitHub mark (CC0); Reicon has none.
|
||||||
const GITHUB =
|
const GITHUB =
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useMode } from "../theme";
|
||||||
|
import { miniature } from "./draw";
|
||||||
|
import type { Block } from "./engine";
|
||||||
|
|
||||||
|
/** Grows once it shows, and again when pressed. */
|
||||||
|
export function Iso({ model, label, className }: { model: Block[]; label: string; className?: string }) {
|
||||||
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
|
const mini = useRef<ReturnType<typeof miniature> | null>(null);
|
||||||
|
const theme = useMode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const m = miniature(canvas.current!, model, still);
|
||||||
|
mini.current = m;
|
||||||
|
const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 });
|
||||||
|
seen.observe(canvas.current!);
|
||||||
|
return () => {
|
||||||
|
seen.disconnect();
|
||||||
|
m.stop();
|
||||||
|
};
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => mini.current?.repaint(), [theme]);
|
||||||
|
|
||||||
|
return <canvas ref={canvas} className={className} role="img" aria-label={label} onClick={() => mini.current?.replay()} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { bounds, faces, inside, painted, rise, type Block, type Point } from "./engine";
|
||||||
|
import { BEAT, strike } from "./marimba";
|
||||||
|
|
||||||
|
const DRAW = BEAT * 2;
|
||||||
|
const GROW = 320;
|
||||||
|
const BUMP = 520;
|
||||||
|
|
||||||
|
interface Ink {
|
||||||
|
ground: string;
|
||||||
|
line: string;
|
||||||
|
accent: string;
|
||||||
|
side: string;
|
||||||
|
shade: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ink(): Ink {
|
||||||
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
const v = (name: string) => css.getPropertyValue(name).trim();
|
||||||
|
return { ground: v("--bg"), line: v("--iso-line"), accent: v("--accent"), side: v("--iso-side"), shade: v("--iso-shade") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grows cell by cell; a block under the pointer lifts and sounds. */
|
||||||
|
export function miniature(canvas: HTMLCanvasElement, model: Block[], still: boolean) {
|
||||||
|
const blocks = painted(model);
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
let colors = ink();
|
||||||
|
let started: number | null = still ? -Infinity : null;
|
||||||
|
let struck = -1;
|
||||||
|
let frame = 0;
|
||||||
|
let view = { scale: 1, dx: 0, dy: 0 };
|
||||||
|
const bumps = new Map<Block, number>();
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const dpr = Math.min(devicePixelRatio, 2);
|
||||||
|
const { clientWidth: w, clientHeight: h } = canvas;
|
||||||
|
canvas.width = Math.round(w * dpr);
|
||||||
|
canvas.height = Math.round(h * dpr);
|
||||||
|
const [x0, y0, x1, y1] = bounds(blocks);
|
||||||
|
const scale = Math.min((w * 0.9) / (x1 - x0), (h * 0.86) / (y1 - y0));
|
||||||
|
view = { scale: scale * dpr, dx: (w * dpr - (x1 + x0) * scale * dpr) / 2, dy: (h * dpr - (y1 + y0) * scale * dpr) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const at = ([x, y]: Point): Point => [x * view.scale + view.dx, y * view.scale + view.dy];
|
||||||
|
|
||||||
|
const path = (poly: Point[], upto = 1) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
const pts = poly.map(at);
|
||||||
|
ctx.moveTo(...pts[0]);
|
||||||
|
// The footprint draws itself edge by edge.
|
||||||
|
const edges = pts.length * upto;
|
||||||
|
for (let i = 1; i <= Math.floor(edges); i++) ctx.lineTo(...pts[i % pts.length]);
|
||||||
|
const part = edges - Math.floor(edges);
|
||||||
|
if (part > 0 && edges < pts.length) {
|
||||||
|
const [a, b] = [pts[Math.floor(edges) % pts.length], pts[(Math.floor(edges) + 1) % pts.length]];
|
||||||
|
ctx.lineTo(a[0] + (b[0] - a[0]) * part, a[1] + (b[1] - a[1]) * part);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (now: number) => {
|
||||||
|
frame = 0;
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineWidth = Math.max(1, view.scale * 0.018);
|
||||||
|
let moving = false;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const since = started === null ? -1 : now - started - b.beat * BEAT;
|
||||||
|
if (since < 0) {
|
||||||
|
moving ||= started !== null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const drawn = Math.min(1, since / DRAW);
|
||||||
|
const grown = rise((since - DRAW) / GROW);
|
||||||
|
const bump = bumps.get(b);
|
||||||
|
const lift = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
|
||||||
|
if (bump !== undefined && lift === 0) bumps.delete(b);
|
||||||
|
moving ||= drawn < 1 || since - DRAW < GROW || lift > 0;
|
||||||
|
const h = Math.max(b.h * grown * (1 + 0.35 * Math.sin(lift * Math.PI)), 0.0001);
|
||||||
|
const f = faces(b, h);
|
||||||
|
const stroke = b.accent ? colors.accent : colors.line;
|
||||||
|
if (drawn < 1) {
|
||||||
|
ctx.strokeStyle = stroke;
|
||||||
|
path(f.top, drawn);
|
||||||
|
ctx.stroke();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [poly, fill] of [[f.right, colors.shade], [f.front, colors.side], [f.top, colors.ground]] as const) {
|
||||||
|
path(poly);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = stroke;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (started !== null && !still) {
|
||||||
|
const beat = Math.floor((now - started) / BEAT);
|
||||||
|
for (; struck < beat; ) {
|
||||||
|
struck++;
|
||||||
|
const first = blocks.find((b) => b.beat === struck);
|
||||||
|
if (first) strike(struck + Math.round(first.h * 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moving) request();
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointer = (e: PointerEvent) => {
|
||||||
|
if (still) return;
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const dpr = canvas.width / r.width;
|
||||||
|
const p: Point = [((e.clientX - r.left) * dpr - view.dx) / view.scale, ((e.clientY - r.top) * dpr - view.dy) / view.scale];
|
||||||
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
|
const b = blocks[i];
|
||||||
|
const f = faces(b, b.h);
|
||||||
|
if (inside(p, f.top) || inside(p, f.front) || inside(p, f.right)) {
|
||||||
|
// The table itself stays put.
|
||||||
|
if (b.y === 0) return;
|
||||||
|
if (!bumps.has(b)) {
|
||||||
|
bumps.set(b, performance.now());
|
||||||
|
strike(Math.round(b.x + b.z + b.h * 2), 0.5);
|
||||||
|
request();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resized = new ResizeObserver(() => (fit(), request()));
|
||||||
|
resized.observe(canvas);
|
||||||
|
canvas.addEventListener("pointermove", pointer);
|
||||||
|
fit();
|
||||||
|
request();
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
// Still, it stands grown from the first frame.
|
||||||
|
if (started !== null) return;
|
||||||
|
started = performance.now();
|
||||||
|
struck = -1;
|
||||||
|
request();
|
||||||
|
},
|
||||||
|
replay() {
|
||||||
|
if (still) return;
|
||||||
|
started = performance.now();
|
||||||
|
struck = -1;
|
||||||
|
request();
|
||||||
|
},
|
||||||
|
repaint() {
|
||||||
|
colors = ink();
|
||||||
|
request();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
resized.disconnect();
|
||||||
|
canvas.removeEventListener("pointermove", pointer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { bounds, faces, inside, painted, project, rise, type Block } from "./engine.ts";
|
||||||
|
|
||||||
|
const block = (x: number, z: number, w: number, d: number, h: number, y = 0): Block => ({ x, z, w, d, h, y, beat: 0 });
|
||||||
|
|
||||||
|
test("the projection keeps vertical lines vertical and the axes at thirty degrees", () => {
|
||||||
|
const [ox, oy] = project(0, 0, 0);
|
||||||
|
const [ux, uy] = project(0, 1, 0);
|
||||||
|
assert.equal(ux, ox);
|
||||||
|
assert.equal(uy, oy - 1);
|
||||||
|
const [xx, xy] = project(1, 0, 0);
|
||||||
|
assert.ok(Math.abs(Math.atan2(xy, xx) - Math.PI / 6) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a block standing on a slab paints after it", () => {
|
||||||
|
const slab = block(0, 0, 10, 10, 0.5);
|
||||||
|
const tower = block(2, 2, 1, 1, 3, 0.5);
|
||||||
|
assert.deepEqual(painted([tower, slab]), [slab, tower]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a block behind another paints first, on either axis", () => {
|
||||||
|
const far = block(0, 0, 1, 1, 1);
|
||||||
|
const nearX = block(2, 0, 1, 1, 1);
|
||||||
|
const nearZ = block(0, 2, 1, 1, 1);
|
||||||
|
assert.deepEqual(painted([nearX, far]), [far, nearX]);
|
||||||
|
assert.deepEqual(painted([nearZ, far]), [far, nearZ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks apart on both axes keep the order they came in", () => {
|
||||||
|
const left = block(0, 3, 1, 1, 1);
|
||||||
|
const right = block(3, 0, 1, 1, 1);
|
||||||
|
assert.deepEqual(painted([left, right]), [left, right]);
|
||||||
|
assert.deepEqual(painted([right, left]), [right, left]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a point inside a top face hits it, one outside misses", () => {
|
||||||
|
const top = faces(block(0, 0, 2, 2, 1), 1).top;
|
||||||
|
const centre = project(1, 1, 1);
|
||||||
|
assert.ok(inside(centre, top));
|
||||||
|
assert.ok(!inside(project(5, 1, 5), top));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounds hold every corner at full height", () => {
|
||||||
|
const [x0, y0, x1, y1] = bounds([block(0, 0, 1, 1, 2)]);
|
||||||
|
const top = project(0, 2, 0);
|
||||||
|
assert.ok(top[1] >= y0 && top[1] <= y1 && top[0] >= x0 && top[0] <= x1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the rise starts at zero, overshoots, and settles at one", () => {
|
||||||
|
assert.equal(rise(0), 0);
|
||||||
|
assert.equal(rise(1), 1);
|
||||||
|
assert.ok(Math.max(...Array.from({ length: 50 }, (_, i) => rise(i / 50))) > 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/** A box on the table, its accent, and its beat. */
|
||||||
|
export interface Block {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
w: number;
|
||||||
|
d: number;
|
||||||
|
y: number;
|
||||||
|
h: number;
|
||||||
|
accent?: boolean;
|
||||||
|
beat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Point = [number, number];
|
||||||
|
|
||||||
|
const COS = Math.cos(Math.PI / 6);
|
||||||
|
|
||||||
|
/** Isometric: x runs down-right, z down-left, y up. */
|
||||||
|
export function project(x: number, y: number, z: number): Point {
|
||||||
|
return [(x - z) * COS, (x + z) * 0.5 - y];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
/** Whether `a` paints before `b`: under or behind it. */
|
||||||
|
function under(a: Block, b: Block): boolean {
|
||||||
|
const apartX = a.x + a.w <= b.x + EPS || b.x + b.w <= a.x + EPS;
|
||||||
|
const apartZ = a.z + a.d <= b.z + EPS || b.z + b.d <= a.z + EPS;
|
||||||
|
if (!apartX && !apartZ) return a.y < b.y || (a.y === b.y && a.h < b.h);
|
||||||
|
// Apart on both axes, they stand side by side.
|
||||||
|
if (apartX && apartZ) return false;
|
||||||
|
return apartX ? a.x + a.w <= b.x + EPS : a.z + a.d <= b.z + EPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to front: each block after what it covers. */
|
||||||
|
export function painted(blocks: Block[]): Block[] {
|
||||||
|
const n = blocks.length;
|
||||||
|
const after: number[][] = blocks.map(() => []);
|
||||||
|
const waiting = new Array<number>(n).fill(0);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
if (i !== j && under(blocks[i], blocks[j])) {
|
||||||
|
after[i].push(j);
|
||||||
|
waiting[j]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ready = blocks.map((_, i) => i).filter((i) => waiting[i] === 0);
|
||||||
|
const out: Block[] = [];
|
||||||
|
while (ready.length) {
|
||||||
|
const i = ready.shift()!;
|
||||||
|
out.push(blocks[i]);
|
||||||
|
for (const j of after[i]) if (--waiting[j] === 0) ready.push(j);
|
||||||
|
}
|
||||||
|
if (out.length !== n) throw new Error(`iso: ${n - out.length} blocks overlap and have no painting order`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three faces the eye sees, at height `h`. */
|
||||||
|
export function faces(b: Block, h: number): { top: Point[]; right: Point[]; front: Point[] } {
|
||||||
|
const [x0, x1, z0, z1, y0, y1] = [b.x, b.x + b.w, b.z, b.z + b.d, b.y, b.y + h];
|
||||||
|
return {
|
||||||
|
top: [project(x0, y1, z0), project(x1, y1, z0), project(x1, y1, z1), project(x0, y1, z1)],
|
||||||
|
right: [project(x1, y0, z0), project(x1, y0, z1), project(x1, y1, z1), project(x1, y1, z0)],
|
||||||
|
front: [project(x0, y0, z1), project(x1, y0, z1), project(x1, y1, z1), project(x0, y1, z1)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `p` lies inside the convex polygon `poly`. */
|
||||||
|
export function inside(p: Point, poly: Point[]): boolean {
|
||||||
|
let sign = 0;
|
||||||
|
for (let i = 0; i < poly.length; i++) {
|
||||||
|
const [a, b] = [poly[i], poly[(i + 1) % poly.length]];
|
||||||
|
const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
|
||||||
|
if (cross === 0) continue;
|
||||||
|
if (sign === 0) sign = Math.sign(cross);
|
||||||
|
else if (Math.sign(cross) !== sign) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The projected box that holds every block at full height. */
|
||||||
|
export function bounds(blocks: Block[]): [number, number, number, number] {
|
||||||
|
let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
|
||||||
|
for (const b of blocks) {
|
||||||
|
const f = faces(b, b.h);
|
||||||
|
for (const [x, y] of [...f.top, ...f.right, ...f.front]) {
|
||||||
|
[x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [x0, y0, x1, y1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back-out easing: a block overshoots its height, then settles. */
|
||||||
|
export function rise(t: number): number {
|
||||||
|
if (t <= 0) return 0;
|
||||||
|
if (t >= 1) return 1;
|
||||||
|
const u = t - 1;
|
||||||
|
return 1 + 2.4 * u ** 3 + 1.4 * u ** 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/** A sixteenth at 132 beats a minute. */
|
||||||
|
export const BEAT = 60_000 / 132 / 4;
|
||||||
|
|
||||||
|
// C major pentatonic, C4 to E6.
|
||||||
|
const SCALE = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26, 28];
|
||||||
|
const LISTENERS = new Set<(on: boolean) => void>();
|
||||||
|
|
||||||
|
let context: AudioContext | null = null;
|
||||||
|
let bus: GainNode | null = null;
|
||||||
|
let on = false;
|
||||||
|
let last = 0;
|
||||||
|
|
||||||
|
export function soundOn(): boolean {
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns sound on or off; browsers need a press first. */
|
||||||
|
export function setSound(next: boolean): void {
|
||||||
|
on = next;
|
||||||
|
if (on && !context) {
|
||||||
|
context = new AudioContext();
|
||||||
|
bus = context.createGain();
|
||||||
|
bus.gain.value = 0.28;
|
||||||
|
const squeeze = context.createDynamicsCompressor();
|
||||||
|
bus.connect(squeeze).connect(context.destination);
|
||||||
|
console.info("marimba: audio started");
|
||||||
|
}
|
||||||
|
if (on) void context!.resume();
|
||||||
|
LISTENERS.forEach((listen) => listen(on));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onSound(listen: (on: boolean) => void): () => void {
|
||||||
|
LISTENERS.add(listen);
|
||||||
|
return () => LISTENERS.delete(listen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strikes the scale's `step`th note; strikes within 30 ms merge. */
|
||||||
|
export function strike(step: number, velocity = 0.7): void {
|
||||||
|
if (!on || !context || !bus) return;
|
||||||
|
const now = context.currentTime;
|
||||||
|
if (now - last < 0.03) return;
|
||||||
|
last = now;
|
||||||
|
const index = ((step % SCALE.length) + SCALE.length) % SCALE.length;
|
||||||
|
const hz = 261.63 * 2 ** (SCALE[index] / 12);
|
||||||
|
// A struck bar: a fundamental, a partial, a click.
|
||||||
|
for (const [ratio, gain, decay] of [
|
||||||
|
[1, 1, 0.9],
|
||||||
|
[3.93, 0.28, 0.18],
|
||||||
|
[9.2, 0.06, 0.05],
|
||||||
|
]) {
|
||||||
|
const tone = context.createOscillator();
|
||||||
|
const env = context.createGain();
|
||||||
|
tone.type = "sine";
|
||||||
|
tone.frequency.value = hz * ratio;
|
||||||
|
env.gain.setValueAtTime(0, now);
|
||||||
|
env.gain.linearRampToValueAtTime(gain * velocity, now + 0.004);
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, now + decay);
|
||||||
|
tone.connect(env).connect(bus);
|
||||||
|
tone.start(now);
|
||||||
|
tone.stop(now + decay + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether sound is on, redrawn when it turns. */
|
||||||
|
export function useSound(): boolean {
|
||||||
|
return useSyncExternalStore((listen) => onSound(listen), soundOn);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Block } from "./engine";
|
||||||
|
|
||||||
|
const b = (x: number, z: number, w: number, d: number, h: number, y = 0, accent = false): Block => ({ x, z, w, d, h, y, accent, beat: 0 });
|
||||||
|
|
||||||
|
/** Beats from the far corner toward the eye. */
|
||||||
|
const sweep = (blocks: Block[], pace: number) => blocks.map((k) => ({ ...k, beat: Math.round((k.x + k.z) * pace) }));
|
||||||
|
|
||||||
|
/** An app: title bar, sidebar, chart, chat, switch. */
|
||||||
|
export function app(): Block[] {
|
||||||
|
const s: Block[] = [b(0, 0, 18, 12, 0.5)];
|
||||||
|
s.push(b(0.6, 0.6, 16.8, 1, 0.25, 0.5));
|
||||||
|
for (let i = 0; i < 3; i++) s.push(b(1 + i * 0.8, 0.85, 0.45, 0.45, 0.25, 0.75, i === 0));
|
||||||
|
for (let i = 0; i < 6; i++) s.push(b(0.8, 2.2 + i * 1.5, 3.4, 1, 0.25, 0.5, i === 1));
|
||||||
|
s.push(b(5, 2.2, 7.4, 1, 0.2, 0.5));
|
||||||
|
s.push(b(5, 3.8, 7.4, 6.4, 0.08, 0.5));
|
||||||
|
[0.9, 1.5, 1.2, 2.1, 1.7, 2.8, 2.3, 3.2].forEach((h, i) => s.push(b(5.5 + i * 0.85, 7.6, 0.55, 1.2, h, 0.58, i === 5)));
|
||||||
|
for (let i = 0; i < 4; i++) s.push(b(i % 2 ? 14.2 : 13, 2.2 + i * 1.7, i % 2 ? 2.8 : 3.6, 1.2, 0.4, 0.5, i === 3));
|
||||||
|
s.push(b(13, 9.8, 1.6, 0.8, 0.3, 0.5), b(13.9, 9.8, 0.7, 0.8, 0.6, 0.5, true));
|
||||||
|
return sweep(s, 0.9);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { chapters, componentCount } from "../data";
|
import { chapters, componentCount } from "../data";
|
||||||
|
import { Iso } from "../iso/Iso";
|
||||||
|
import { app } from "../iso/models";
|
||||||
|
|
||||||
|
const APP = app();
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -8,6 +12,7 @@ export function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
|
<Iso model={APP} label="A miniature app growing cell by cell" className="hero-iso" />
|
||||||
<h1>Components for GPUI.</h1>
|
<h1>Components for GPUI.</h1>
|
||||||
<p>
|
<p>
|
||||||
{componentCount} components in {chapters.length} chapters, live in the browser.
|
{componentCount} components in {chapters.length} chapters, live in the browser.
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,9 @@
|
|||||||
--line: #e6e3df;
|
--line: #e6e3df;
|
||||||
--accent: #3772bb;
|
--accent: #3772bb;
|
||||||
--overlay: #ffffff;
|
--overlay: #ffffff;
|
||||||
|
--iso-line: #1a1918;
|
||||||
|
--iso-side: #f3f0ec;
|
||||||
|
--iso-shade: #e9e5df;
|
||||||
--sans: "Geist Variable", system-ui, sans-serif;
|
--sans: "Geist Variable", system-ui, sans-serif;
|
||||||
--mono: "Geist Mono Variable", ui-monospace, monospace;
|
--mono: "Geist Mono Variable", ui-monospace, monospace;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -19,6 +22,9 @@
|
|||||||
--line: #262422;
|
--line: #262422;
|
||||||
--accent: #69a1e8;
|
--accent: #69a1e8;
|
||||||
--overlay: #1b1917;
|
--overlay: #1b1917;
|
||||||
|
--iso-line: #d9d5d0;
|
||||||
|
--iso-side: #1a1816;
|
||||||
|
--iso-shade: #201e1c;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +69,9 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|||||||
main { min-height: 100vh; }
|
main { min-height: 100vh; }
|
||||||
|
|
||||||
/* Pages */
|
/* Pages */
|
||||||
.hero { min-height: 100vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 96px 64px; }
|
.hero { position: relative; min-height: 100vh; display: grid; align-content: end; justify-items: start; gap: 24px; padding: 96px 64px; }
|
||||||
|
.hero-iso { position: absolute; top: 64px; right: 0; width: 64%; height: calc(100% - 64px); cursor: crosshair; }
|
||||||
|
.hero > :not(canvas) { position: relative; z-index: 1; max-width: 44%; }
|
||||||
.hero h1 { font-size: clamp(48px, 8vw, 128px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
|
.hero h1 { font-size: clamp(48px, 8vw, 128px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
|
||||||
.hero p { font-size: 20px; line-height: 30px; }
|
.hero p { font-size: 20px; line-height: 30px; }
|
||||||
|
|
||||||
@@ -111,6 +119,8 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
|||||||
.hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; }
|
.hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; }
|
||||||
.foot { padding: 32px 20px; }
|
.foot { padding: 32px 20px; }
|
||||||
.chapters .name { font-size: 22px; }
|
.chapters .name { font-size: 22px; }
|
||||||
|
.hero-iso { width: 100%; height: 55%; }
|
||||||
|
.hero > :not(canvas) { max-width: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "e2e"]
|
"include": ["src", "e2e"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user