T45 Website: an isometric miniature grows to a marimba; finance, charts and chat beside their models; no bars, no boxes

This commit is contained in:
ZacharyZhang-NY committed 2026-09-29 23:40:04 -04:00
1 parent f50a708951
commit 587e08b5cb
9 files changed
+542 -198

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
/** 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; the first turn on needs a press, as browsers ask. */
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, soft or hard; 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: the fundamental, a partial near four times up, a short 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);
}
}
+95
View File
@@ -0,0 +1,95 @@
export type Tone = "paper" | "ink" | "soft" | "accent";
/** A box on the table: cell, footprint, lift, height, tone, the beat it grows on. */
export interface Block {
x: number;
z: number;
w: number;
d: number;
y: number;
h: number;
tone: Tone;
beat: number;
}
export type Model = { blocks: Block[]; span: [number, number] };
const block = (x: number, z: number, w: number, d: number, h: number, tone: Tone, y = 0): Block => ({
x, z, w, d, y, h, tone, beat: 0,
});
/** Beats from the far corner toward the eye, one diagonal a step. */
function sweep(blocks: Block[], pace = 1): Block[] {
return blocks.map((b) => ({ ...b, beat: b.beat || Math.round((b.x + b.z) * pace) }));
}
/** A small app on its slab: a sidebar, a chart, a chat, a switch. */
export function app(): Model {
const b: Block[] = [block(0, 0, 18, 12, 0.5, "paper")];
b.push(block(0.5, 0.5, 17, 1, 0.3, "soft", 0.5));
for (let i = 0; i < 3; i++) b.push(block(1 + i * 0.9, 0.8, 0.5, 0.4, 0.3, i === 0 ? "accent" : "ink", 0.8));
for (let i = 0; i < 6; i++) b.push(block(0.8, 2.2 + i * 1.5, 3.4, 1, 0.3, i === 1 ? "accent" : "soft", 0.5));
b.push(block(5, 2.2, 7.4, 1, 0.25, "soft", 0.5));
b.push(block(5, 4, 7.4, 5.6, 0.12, "soft", 0.5));
const bars = [0.9, 1.5, 1.2, 2.1, 1.7, 2.8, 2.3, 3.2];
bars.forEach((h, i) => b.push(block(5.4 + i * 0.85, 7.2, 0.55, 1.2, h, i === 5 ? "accent" : "ink", 0.62)));
b.push(block(5.4, 10.4, 6.6, 0.3, 0.15, "soft", 0.5));
b.push(block(8.4, 10.3, 0.5, 0.5, 0.5, "accent", 0.5));
for (let i = 0; i < 4; i++) {
const mine = i % 2 === 1;
b.push(block(mine ? 14.2 : 13, 2.2 + i * 1.7, mine ? 2.8 : 3.6, 1.2, 0.5, mine ? "ink" : "soft", 0.5));
}
b.push(block(13, 9.6, 1.6, 0.8, 0.35, "soft", 0.5));
b.push(block(13.9, 9.6, 0.7, 0.8, 0.7, "accent", 0.5));
return { blocks: sweep(b, 0.9), span: [18, 12] };
}
/** Twenty candles and their volume, rising in the accent, falling in ink. */
export function candles(): Model {
const b: Block[] = [block(0, 0, 21, 8, 0.3, "paper")];
const days: Array<[number, number, number, number]> = [];
let close = 0;
for (let i = 0; i < 20; i++) {
const open = close;
close = open + Math.sin(i * 1.7) * 1.1 + Math.cos(i * 0.6) * 0.6 + 0.25;
days.push([open, close, Math.min(open, close) - 0.4 - (i % 3) * 0.2, Math.max(open, close) + 0.35 + (i % 2) * 0.3]);
}
// Prices fill 0.9 to 4.5 above the slab.
const floor = Math.min(...days.map((d) => d[2]));
const range = Math.max(...days.map((d) => d[3])) - floor;
const lift = (price: number) => 0.9 + ((price - floor) / range) * 3.6;
days.forEach(([open, close, low, high], i) => {
const x = 0.7 + i;
b.push({ ...block(x + 0.33, 2.73, 0.14, 0.14, lift(high) - lift(low), "soft", lift(low)), beat: 2 + i * 2 });
const [bottom, top] = [lift(Math.min(open, close)), lift(Math.max(open, close))];
b.push({ ...block(x, 2.4, 0.8, 0.8, Math.max(top - bottom, 0.2), close >= open ? "accent" : "ink", bottom), beat: 3 + i * 2 });
b.push({ ...block(x, 5, 0.8, 1.6, 0.2 + ((i * 7) % 5) * 0.22, "soft", 0.3), beat: 3 + i * 2 });
});
return { blocks: sweep(b), span: [21, 8] };
}
/** Five series of eight months, one in the accent. */
export function bars(): Model {
const b: Block[] = [block(0, 0, 11, 8, 0.3, "paper")];
for (let z = 0; z < 5; z++) {
for (let x = 0; x < 8; x++) {
const h = 0.4 + 2.2 * (0.5 + 0.5 * Math.sin(x * 0.7 + z * 1.1)) * (1 + z * 0.12);
const tone: Tone = z === 2 ? "accent" : z === 4 ? "ink" : "soft";
b.push({ ...block(1 + x * 1.2, 1 + z * 1.3, 0.8, 0.8, h, tone, 0.3), beat: x * 2 + z });
}
}
return { blocks: b, span: [11, 8] };
}
/** A conversation: turns left and right, and three dots still typing. */
export function chat(): Model {
const b: Block[] = [block(0, 0, 10, 12, 0.3, "paper")];
const turns: Array<[boolean, number]> = [[false, 6], [true, 4.4], [false, 7.2], [true, 3.2], [false, 5.4]];
turns.forEach(([mine, w], i) => {
const z = 1 + i * 2;
if (!mine) b.push({ ...block(0.8, z + 0.3, 0.8, 0.8, 0.8, "ink", 0.3), beat: i * 4 });
b.push({ ...block(mine ? 9.2 - w : 2, z, w, 1.4, 0.45, mine ? "accent" : "soft", 0.3), beat: i * 4 + 1 });
});
for (let i = 0; i < 3; i++) b.push({ ...block(2.2 + i * 0.7, 10.9, 0.45, 0.45, 0.45, "ink", 0.3), beat: 21 + i });
return { blocks: b, span: [10, 12] };
}
+200
View File
@@ -0,0 +1,200 @@
import * as THREE from "three";
import { BEAT, strike } from "./marimba";
import type { Block, Model, Tone } from "./models";
const GROW = 260;
const BUMP = 420;
interface Scene {
host: HTMLElement;
model: Model;
world: THREE.Scene;
camera: THREE.OrthographicCamera;
mesh: THREE.InstancedMesh;
grid: THREE.LineSegments;
started: number | null;
struck: number;
bumps: Map<number, number>;
}
const box = new THREE.BoxGeometry(1, 1, 1).translate(0.5, 0.5, 0.5);
const at = new THREE.Object3D();
const ray = new THREE.Raycaster();
const tones: Record<Tone, THREE.Color> = { paper: new THREE.Color(), ink: new THREE.Color(), soft: new THREE.Color(), accent: new THREE.Color() };
const lines = new THREE.Color();
/** Back-out easing: a block overshoots, then settles on its height. */
const rise = (t: number) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
/** Isometric dioramas, one canvas over the page, each drawn in its host's box. */
export function stage(still: boolean) {
const canvas = document.createElement("canvas");
canvas.className = "iso";
canvas.setAttribute("aria-hidden", "true");
document.body.append(canvas);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setScissorTest(true);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scenes: Scene[] = [];
let frame = 0;
const paint = () => {
const css = getComputedStyle(document.documentElement);
for (const tone of Object.keys(tones) as Tone[]) tones[tone].set(css.getPropertyValue(`--iso-${tone}`).trim());
lines.set(css.getPropertyValue("--line").trim());
for (const s of scenes) {
s.model.blocks.forEach((b, i) => s.mesh.setColorAt(i, tones[b.tone]));
s.mesh.instanceColor!.needsUpdate = true;
(s.grid.material as THREE.LineBasicMaterial).color.copy(lines);
}
};
const place = (s: Scene, now: number) => {
let moving = false;
s.model.blocks.forEach((b: Block, i) => {
const since = s.started === null ? -1 : now - s.started - b.beat * BEAT;
const grown = still ? 1 : since < 0 ? 0 : since >= GROW ? 1 : rise(since / GROW);
const bump = s.bumps.get(i);
const lift = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
if (bump !== undefined && lift === 0) s.bumps.delete(i);
moving ||= (since >= 0 && since < GROW) || lift > 0 || (s.started !== null && since < 0);
at.position.set(b.x, b.y, b.z);
at.scale.set(b.w, Math.max(b.h * grown * (1 + 0.5 * Math.sin(lift * Math.PI)), 0.0001), b.d);
at.updateMatrix();
s.mesh.setMatrixAt(i, at.matrix);
});
s.mesh.instanceMatrix.needsUpdate = true;
// One note for each beat that starts a block.
if (s.started !== null && !still) {
const beat = Math.floor((now - s.started) / BEAT);
for (; s.struck <= beat; s.struck++) {
const first = s.model.blocks.findIndex((b) => b.beat === s.struck);
if (first >= 0) strike(s.struck + Math.round(s.model.blocks[first].h));
}
}
return moving;
};
const draw = (now: number) => {
frame = 0;
const { innerWidth: w, innerHeight: h } = window;
renderer.setSize(w, h, false);
renderer.setScissor(0, 0, w, h);
renderer.clear();
let again = false;
for (const s of scenes) {
const r = s.host.getBoundingClientRect();
if (r.bottom < 0 || r.top > h || r.width === 0) continue;
again = place(s, now) || again;
fit(s, r.width / r.height);
renderer.setViewport(r.left, h - r.bottom, r.width, r.height);
renderer.setScissor(r.left, h - r.bottom, r.width, r.height);
renderer.render(s.world, s.camera);
}
if (again) request();
};
const request = () => {
if (!frame) frame = requestAnimationFrame(draw);
};
const seen = new IntersectionObserver((entries) => {
for (const e of entries) {
const s = scenes.find((x) => x.host === e.target);
if (s && e.isIntersecting && s.started === null) {
s.started = performance.now();
console.info(`iso: ${s.host.dataset.model} grows`);
}
}
request();
}, { threshold: 0.35 });
addEventListener("scroll", request, { passive: true });
addEventListener("resize", request);
return {
/** Grows `model` in `host` once it shows; pointing at a block strikes it. */
add(host: HTMLElement, model: Model) {
const world = new THREE.Scene();
world.add(new THREE.HemisphereLight(0xffffff, 0x9a9a9a, 1.6));
const [w, d] = model.span;
const sun = new THREE.DirectionalLight(0xffffff, 1.9);
sun.position.set(w / 2 - 12, 30, d / 2 + 7);
sun.target.position.set(w / 2, 0, d / 2);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.radius = 4;
sun.shadow.bias = -0.0006;
Object.assign(sun.shadow.camera, { left: -w, right: w, top: w, bottom: -w, near: 1, far: 90 });
world.add(sun, sun.target);
const mesh = new THREE.InstancedMesh(box, new THREE.MeshLambertMaterial(), model.blocks.length);
mesh.castShadow = mesh.receiveShadow = true;
// The table: its grid, and a floor that shows only the shadow the model casts.
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w * 3, d * 3).rotateX(-Math.PI / 2), new THREE.ShadowMaterial({ opacity: 0.12 }));
floor.position.set(w / 2, -0.001, d / 2);
floor.receiveShadow = true;
const grid = new THREE.LineSegments(table(w, d), new THREE.LineBasicMaterial({ transparent: true, opacity: 0.9 }));
world.add(mesh, floor, grid);
const s: Scene = { host, model, world, camera: new THREE.OrthographicCamera(), mesh, grid, started: null, struck: 0, bumps: new Map() };
scenes.push(s);
paint();
place(s, performance.now());
host.addEventListener("pointermove", (e) => {
const r = host.getBoundingClientRect();
ray.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), s.camera);
const hit = ray.intersectObject(mesh)[0];
if (hit?.instanceId === undefined || hit.instanceId === 0 || s.bumps.has(hit.instanceId)) return;
s.bumps.set(hit.instanceId, performance.now());
strike(hit.instanceId + Math.round(model.blocks[hit.instanceId].h), 0.5);
request();
});
host.addEventListener("click", () => {
s.started = performance.now();
s.struck = 0;
request();
});
seen.observe(host);
},
paint: () => (paint(), request()),
stop() {
cancelAnimationFrame(frame);
seen.disconnect();
removeEventListener("scroll", request);
removeEventListener("resize", request);
for (const s of scenes) (s.mesh.material as THREE.Material).dispose();
renderer.dispose();
canvas.remove();
},
};
}
/** Grid lines a cell apart around the slab, a drafting table under the model. */
function table(w: number, d: number): THREE.BufferGeometry {
const points: number[] = [];
const [x0, x1, z0, z1] = [-4, w + 4, -4, d + 4];
for (let x = x0; x <= x1; x++) points.push(x, 0, z0, x, 0, z1);
for (let z = z0; z <= z1; z++) points.push(x0, 0, z, x1, 0, z);
return new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(points, 3));
}
/** A true isometric view that keeps the model's table and tallest block in the box. */
function fit(s: Scene, aspect: number) {
const [w, d] = s.model.span;
const tall = Math.max(...s.model.blocks.map((b) => b.y + b.h));
const centre = new THREE.Vector3(w / 2, tall / 3, d / 2);
s.camera.position.copy(centre).add(new THREE.Vector3(1, 1, 1).multiplyScalar(60));
s.camera.up.set(0, 1, 0);
s.camera.lookAt(centre);
// The table's diagonal and the tallest block set the half extents.
const across = ((w + d) * Math.SQRT1_2) / 2 + 0.6;
const high = ((w + d) * 0.41) / 2 + tall * 0.82 / 2 + 0.6;
const half = Math.max(across / aspect, high);
s.camera.left = -half * aspect;
s.camera.right = half * aspect;
s.camera.top = half;
s.camera.bottom = -half;
s.camera.near = 0.1;
s.camera.far = 400;
s.camera.updateProjectionMatrix();
}