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:
1 parent
7fd05df4dc
commit
b47f2267e4
10 files changed
+355
-49
No files matched your search
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user