T45b Site · Iso: diagonal cells in order, the pointer meets only grown cells, motion follows the system live, replay by key, the header fits 280px
This commit is contained in:
5 files changed
+46
-13
No files matched your search
@@ -1,16 +1,25 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
import { useMode } from "../theme";
|
import { useMode } from "../theme";
|
||||||
import { miniature } from "./draw";
|
import { miniature } from "./draw";
|
||||||
import type { Block } from "./engine";
|
import type { Block } from "./engine";
|
||||||
|
|
||||||
/** Grows once it shows, and again when pressed. */
|
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
|
||||||
|
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 }) {
|
export function Iso({ model, label, className }: { model: Block[]; label: string; className?: string }) {
|
||||||
const canvas = useRef<HTMLCanvasElement>(null);
|
const canvas = useRef<HTMLCanvasElement>(null);
|
||||||
const mini = useRef<ReturnType<typeof miniature> | null>(null);
|
const mini = useRef<ReturnType<typeof miniature> | null>(null);
|
||||||
const theme = useMode();
|
const theme = useMode();
|
||||||
|
const still = useStill();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
const m = miniature(canvas.current!, model, still);
|
const m = miniature(canvas.current!, model, still);
|
||||||
mini.current = m;
|
mini.current = m;
|
||||||
const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 });
|
const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 });
|
||||||
@@ -19,9 +28,15 @@ export function Iso({ model, label, className }: { model: Block[]; label: string
|
|||||||
seen.disconnect();
|
seen.disconnect();
|
||||||
m.stop();
|
m.stop();
|
||||||
};
|
};
|
||||||
}, [model]);
|
}, [model, still]);
|
||||||
|
|
||||||
useEffect(() => mini.current?.repaint(), [theme]);
|
useEffect(() => mini.current?.repaint(), [theme]);
|
||||||
|
|
||||||
return <canvas ref={canvas} className={className} role="img" aria-label={label} onClick={() => mini.current?.replay()} />;
|
// Still, there is nothing to replay.
|
||||||
|
if (still) return <canvas ref={canvas} className={`iso ${className ?? ""}`} role="img" aria-label={label} />;
|
||||||
|
return (
|
||||||
|
<button className={`iso ${className ?? ""}`} aria-label={`${label}. Grow it again`} onClick={() => mini.current?.replay()}>
|
||||||
|
<canvas ref={canvas} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,8 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
|
|||||||
let frame = 0;
|
let frame = 0;
|
||||||
let view = { scale: 1, dx: 0, dy: 0 };
|
let view = { scale: 1, dx: 0, dy: 0 };
|
||||||
const bumps = new Map<Block, number>();
|
const bumps = new Map<Block, number>();
|
||||||
|
// Heights as last drawn; only grown blocks answer the pointer.
|
||||||
|
const shown = new Map<Block, number>();
|
||||||
|
|
||||||
const fit = () => {
|
const fit = () => {
|
||||||
const dpr = Math.min(devicePixelRatio, 2);
|
const dpr = Math.min(devicePixelRatio, 2);
|
||||||
@@ -63,6 +65,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
|
|||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
ctx.lineWidth = Math.max(1, view.scale * 0.018);
|
ctx.lineWidth = Math.max(1, view.scale * 0.018);
|
||||||
let moving = false;
|
let moving = false;
|
||||||
|
shown.clear();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const since = started === null ? -1 : now - started - b.beat * BEAT;
|
const since = started === null ? -1 : now - started - b.beat * BEAT;
|
||||||
if (since < 0) {
|
if (since < 0) {
|
||||||
@@ -77,6 +80,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
|
|||||||
moving ||= drawn < 1 || since - DRAW < GROW || lift > 0;
|
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 h = Math.max(b.h * grown * (1 + 0.35 * Math.sin(lift * Math.PI)), 0.0001);
|
||||||
const f = faces(b, h);
|
const f = faces(b, h);
|
||||||
|
if (drawn === 1) shown.set(b, h);
|
||||||
const stroke = b.accent ? colors.accent : colors.line;
|
const stroke = b.accent ? colors.accent : colors.line;
|
||||||
if (drawn < 1) {
|
if (drawn < 1) {
|
||||||
ctx.strokeStyle = stroke;
|
ctx.strokeStyle = stroke;
|
||||||
@@ -115,7 +119,9 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
|
|||||||
const p: Point = [((e.clientX - r.left) * dpr - view.dx) / view.scale, ((e.clientY - r.top) * dpr - view.dy) / view.scale];
|
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--) {
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
const b = blocks[i];
|
const b = blocks[i];
|
||||||
const f = faces(b, b.h);
|
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)) {
|
if (inside(p, f.top) || inside(p, f.front) || inside(p, f.right)) {
|
||||||
// The table itself stays put.
|
// The table itself stays put.
|
||||||
if (b.y === 0) return;
|
if (b.y === 0) return;
|
||||||
@@ -144,6 +150,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
|
|||||||
},
|
},
|
||||||
replay() {
|
replay() {
|
||||||
if (still) return;
|
if (still) return;
|
||||||
|
shown.clear();
|
||||||
started = performance.now();
|
started = performance.now();
|
||||||
struck = -1;
|
struck = -1;
|
||||||
request();
|
request();
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ test("a block behind another paints first, on either axis", () => {
|
|||||||
assert.deepEqual(painted([nearZ, far]), [far, nearZ]);
|
assert.deepEqual(painted([nearZ, far]), [far, nearZ]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blocks apart on both axes keep the order they came in", () => {
|
test("a block behind on both axes paints first", () => {
|
||||||
|
const far = block(0, 0, 1, 1, 4, 0.5);
|
||||||
|
const near = block(2, 2, 1, 1, 4, 0.5);
|
||||||
|
assert.deepEqual(painted([near, far]), [far, near]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks side by side keep the order they came in", () => {
|
||||||
const left = block(0, 3, 1, 1, 1);
|
const left = block(0, 3, 1, 1, 1);
|
||||||
const right = block(3, 0, 1, 1, 1);
|
const right = block(3, 0, 1, 1, 1);
|
||||||
assert.deepEqual(painted([left, right]), [left, right]);
|
assert.deepEqual(painted([left, right]), [left, right]);
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ function under(a: Block, b: Block): boolean {
|
|||||||
const apartX = a.x + a.w <= b.x + EPS || b.x + b.w <= a.x + EPS;
|
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;
|
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);
|
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.
|
const [behindX, behindZ] = [a.x + a.w <= b.x + EPS, a.z + a.d <= b.z + EPS];
|
||||||
if (apartX && apartZ) return false;
|
// Behind on one axis, before on the other: side by side.
|
||||||
return apartX ? a.x + a.w <= b.x + EPS : a.z + a.d <= b.z + EPS;
|
if (apartX && apartZ) return behindX && behindZ;
|
||||||
|
return apartX ? behindX : behindZ;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back to front: each block after what it covers. */
|
/** Back to front: each block after what it covers. */
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ main { min-height: 100vh; }
|
|||||||
/* Pages */
|
/* Pages */
|
||||||
.hero { position: relative; min-height: 100vh; display: grid; align-content: end; 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-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%; }
|
.iso { display: block; padding: 0; border: 1px solid transparent; }
|
||||||
|
.iso canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.iso:focus-visible { outline: none; border-color: var(--accent); }
|
||||||
|
.hero > :not(.iso) { 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; }
|
||||||
|
|
||||||
@@ -115,12 +118,13 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
|||||||
.foot a { text-decoration: underline; text-underline-offset: 3px; }
|
.foot a { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.bar { padding: 0 16px; gap: 20px; }
|
.bar { padding: 0 16px; gap: 12px; }
|
||||||
|
.bar .end { gap: 0; }
|
||||||
.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-iso { width: 100%; height: 55%; }
|
||||||
.hero > :not(canvas) { max-width: none; }
|
.hero > :not(.iso) { max-width: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
Reference in new issue
Block a user