T45c Site · Home: a full-screen hero, five live features beside their miniatures, counts on the beat, the chapters as a city, a closing call; smooth scroll, reveals from scroll, a fade between routes; the OG image from the new hero

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 08:38:05 -04:00
1 parent f9c20b251c
commit 23ebaba2a1
10 files changed
+355 -49

No files matched your search

+28 -7
View File
@@ -5,22 +5,34 @@ import type { Block } from "./engine";
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)");
function useStill(): boolean {
/** Whether the system asks for reduced motion, kept current. */
export function useStill(): boolean {
return useSyncExternalStore(
(listen) => (REDUCED.addEventListener("change", listen), () => REDUCED.removeEventListener("change", listen)),
() => REDUCED.matches,
);
}
/** Grows once it shows; a press grows it again. */
export function Iso({ model, label, className }: { model: Block[]; label: string; className?: string }) {
interface Props {
model: Block[];
label: string;
className?: string;
onHover?: (index: number | null) => void;
onPick?: (index: number) => void;
lift?: number | null;
}
/** Grows once shown; a press regrows it or picks a block. */
export function Iso({ model, label, className, onHover, onPick, lift }: Props) {
const canvas = useRef<HTMLCanvasElement>(null);
const mini = useRef<ReturnType<typeof miniature> | null>(null);
const hover = useRef(onHover);
hover.current = onHover;
const theme = useMode();
const still = useStill();
useEffect(() => {
const m = miniature(canvas.current!, model, still);
const m = miniature(canvas.current!, model, still, (i) => hover.current?.(i));
mini.current = m;
const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 });
seen.observe(canvas.current!);
@@ -31,11 +43,20 @@ export function Iso({ model, label, className }: { model: Block[]; label: string
}, [model, still]);
useEffect(() => mini.current?.repaint(), [theme]);
useEffect(() => {
if (lift != null) mini.current?.lift(lift);
}, [lift]);
// Still, there is nothing to replay.
if (still) return <canvas ref={canvas} className={`iso ${className ?? ""}`} role="img" aria-label={label} />;
const press = (x: number, y: number) => {
const at = onPick && mini.current?.pick(x, y);
if (at != null) onPick!(at);
else mini.current?.replay();
};
const name = `iso ${className ?? ""}`;
// Still, nothing replays; picks stay the pointer's.
if (still) return <canvas ref={canvas} className={name} role="img" aria-label={label} onClick={(e) => press(e.clientX, e.clientY)} />;
return (
<button className={`iso ${className ?? ""}`} aria-label={`${label}. Grow it again`} onClick={() => mini.current?.replay()}>
<button className={name} aria-label={`${label}. Grow it again`} onClick={(e) => (e.detail ? press(e.clientX, e.clientY) : mini.current?.replay())}>
<canvas ref={canvas} />
</button>
);
+33 -19
View File
@@ -19,8 +19,8 @@ function ink(): Ink {
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) {
/** Grows cell by cell; a pointed block lifts and sounds. */
export function miniature(canvas: HTMLCanvasElement, model: Block[], still: boolean, onHover?: (index: number | null) => void) {
const blocks = painted(model);
const ctx = canvas.getContext("2d")!;
let colors = ink();
@@ -75,10 +75,10 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
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 bounce = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
if (bump !== undefined && bounce === 0) bumps.delete(b);
moving ||= drawn < 1 || since - DRAW < GROW || bounce > 0;
const h = Math.max(b.h * grown * (1 + 0.35 * Math.sin(bounce * Math.PI)), 0.0001);
const f = faces(b, h);
if (drawn === 1) shown.set(b, h);
const stroke = b.accent ? colors.accent : colors.line;
@@ -112,35 +112,48 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
if (!frame) frame = requestAnimationFrame(draw);
};
const pointer = (e: PointerEvent) => {
if (still) return;
/** Model index of the grown block at a point, not the table. */
const pick = (x: number, y: number): number | null => {
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];
const p: Point = [((x - r.left) * dpr - view.dx) / view.scale, ((y - r.top) * dpr - view.dy) / view.scale];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
const h = shown.get(b);
if (h === undefined) continue;
const f = faces(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;
}
if (inside(p, f.top) || inside(p, f.front) || inside(p, f.right)) return b.y === 0 ? null : model.indexOf(b);
}
return null;
};
const lift = (index: number) => {
const b = model[index];
if (still || bumps.has(b)) return;
bumps.set(b, performance.now());
strike(Math.round(b.x + b.z + b.h * 2), 0.5);
request();
};
let hovered: number | null = null;
const hover = (at: number | null) => {
if (at === hovered) return;
hovered = at;
if (at !== null) lift(at);
onHover?.(at);
};
const pointer = (e: PointerEvent) => hover(pick(e.clientX, e.clientY));
const leave = () => hover(null);
const resized = new ResizeObserver(() => (fit(), request()));
resized.observe(canvas);
canvas.addEventListener("pointermove", pointer);
canvas.addEventListener("pointerleave", leave);
fit();
request();
return {
pick,
lift,
start() {
// Still, it stands grown from the first frame.
if (started !== null) return;
@@ -163,6 +176,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
cancelAnimationFrame(frame);
resized.disconnect();
canvas.removeEventListener("pointermove", pointer);
canvas.removeEventListener("pointerleave", leave);
},
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ function under(a: Block, b: Block): boolean {
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);
const [behindX, behindZ] = [a.x + a.w <= b.x + EPS, a.z + a.d <= b.z + EPS];
// Behind on one axis, before on the other: side by side.
// Behind on one axis, before on the other: apart.
if (apartX && apartZ) return behindX && behindZ;
return apartX ? behindX : behindZ;
}
+81
View File
@@ -18,3 +18,84 @@ export function app(): Block[] {
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);
}
/** Twenty candles over their volume. */
export function candles(): Block[] {
const s: Block[] = [b(0, 0, 21, 8, 0.3)];
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]);
}
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) * 4;
days.forEach(([open, close, low, high], i) => {
const x = 0.7 + i;
const [lo, hi] = [lift(Math.min(open, close)), lift(Math.max(open, close))];
s.push({ ...b(x + 0.33, 2.73, 0.14, 0.14, lift(high) - lift(low), lift(low)), beat: 2 + i });
s.push({ ...b(x, 2.4, 0.8, 0.8, Math.max(hi - lo, 0.2), lo, close >= open), beat: 2 + i });
s.push({ ...b(x, 5, 0.8, 1.6, 0.2 + ((i * 7) % 5) * 0.22, 0.3), beat: 3 + i });
});
return s;
}
/** Five series of eight months, one in the accent. */
export function bars(): Block[] {
const s: Block[] = [b(0, 0, 11, 8, 0.3)];
for (let z = 0; z < 5; z++) {
for (let x = 0; x < 8; x++) {
const h = 0.4 + 2.4 * (0.5 + 0.5 * Math.sin(x * 0.7 + z * 1.1)) * (1 + z * 0.12);
s.push({ ...b(1 + x * 1.2, 1 + z * 1.3, 0.8, 0.8, h, 0.3, z === 2), beat: 1 + x + z });
}
}
return s;
}
/** A conversation, turn by turn, three dots still typing. */
export function chat(): Block[] {
const s: Block[] = [b(0, 0, 10, 12, 0.3)];
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) s.push({ ...b(0.8, z + 0.3, 0.8, 0.8, 0.8, 0.3), beat: 1 + i * 3 });
s.push({ ...b(mine ? 9.2 - w : 2, z, w, 1.4, 0.45, 0.3, mine), beat: 2 + i * 3 });
});
for (let i = 0; i < 3; i++) s.push({ ...b(2.2 + i * 0.7, 10.9, 0.45, 0.45, 0.45, 0.3, true), beat: 17 + i });
return s;
}
/** Indented code beside its gutter, the cursor's line raised. */
export function editor(): Block[] {
const s: Block[] = [b(0, 0, 14, 11, 0.3)];
const lines = [6, 8, 5, 0, 9, 7, 10, 4, 0, 8, 6, 3];
const indent = [0, 1, 2, 0, 0, 1, 2, 3, 0, 1, 1, 0];
lines.forEach((w, i) => {
const z = 0.8 + i * 0.8;
s.push({ ...b(0.7, z, 0.6, 0.45, 0.2, 0.3), beat: 1 + i });
if (w) s.push({ ...b(2 + indent[i] * 0.8, z, w * 0.9, 0.45, i === 6 ? 0.7 : 0.25, 0.3, i === 6), beat: 1 + i });
});
return s;
}
/** A month of days, a few holding events. */
export function calendar(): Block[] {
const s: Block[] = [b(0, 0, 15, 11, 0.3)];
for (let i = 0; i < 35; i++) {
const [col, row] = [i % 7, Math.floor(i / 7)];
const busy = [3, 8, 9, 15, 22, 23, 24, 30].includes(i);
s.push({ ...b(0.7 + col * 2, 0.7 + row * 2, 1.7, 1.7, busy ? 0.6 + (i % 3) * 0.3 : 0.15, 0.3, i === 23), beat: 1 + col + row });
}
return s;
}
/** Every chapter a plinth, as tall as its stories. */
export function city(counts: number[]): Block[] {
const s: Block[] = [b(0, 0, 25, 16, 0.3)];
counts.forEach((n, i) => {
const [cx, cz] = [1 + (i % 9) * 2.6, 1 + Math.floor(i / 9) * 3];
s.push({ ...b(cx, cz, 1.6, 1.6, 0.4 + n * 0.1, 0.3), beat: 2 + i });
});
return s;
}