diff --git a/frontend/src/iso/Iso.tsx b/frontend/src/iso/Iso.tsx index d21df15..9c1ece9 100644 --- a/frontend/src/iso/Iso.tsx +++ b/frontend/src/iso/Iso.tsx @@ -1,16 +1,25 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { useMode } from "../theme"; import { miniature } from "./draw"; 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 }) { const canvas = useRef(null); const mini = useRef | null>(null); const theme = useMode(); + const still = useStill(); 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 }); @@ -19,9 +28,15 @@ export function Iso({ model, label, className }: { model: Block[]; label: string seen.disconnect(); m.stop(); }; - }, [model]); + }, [model, still]); useEffect(() => mini.current?.repaint(), [theme]); - return mini.current?.replay()} />; + // Still, there is nothing to replay. + if (still) return ; + return ( + + ); } diff --git a/frontend/src/iso/draw.ts b/frontend/src/iso/draw.ts index 589f323..85d7221 100644 --- a/frontend/src/iso/draw.ts +++ b/frontend/src/iso/draw.ts @@ -29,6 +29,8 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool let frame = 0; let view = { scale: 1, dx: 0, dy: 0 }; const bumps = new Map(); + // Heights as last drawn; only grown blocks answer the pointer. + const shown = new Map(); const fit = () => { const dpr = Math.min(devicePixelRatio, 2); @@ -63,6 +65,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool ctx.lineJoin = "round"; ctx.lineWidth = Math.max(1, view.scale * 0.018); let moving = false; + shown.clear(); for (const b of blocks) { const since = started === null ? -1 : now - started - b.beat * BEAT; if (since < 0) { @@ -77,6 +80,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool 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); + if (drawn === 1) shown.set(b, h); const stroke = b.accent ? colors.accent : colors.line; if (drawn < 1) { 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]; for (let i = blocks.length - 1; i >= 0; 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)) { // The table itself stays put. if (b.y === 0) return; @@ -144,6 +150,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool }, replay() { if (still) return; + shown.clear(); started = performance.now(); struck = -1; request(); diff --git a/frontend/src/iso/engine.test.ts b/frontend/src/iso/engine.test.ts index 1f60d3b..7ec94d9 100644 --- a/frontend/src/iso/engine.test.ts +++ b/frontend/src/iso/engine.test.ts @@ -27,7 +27,13 @@ test("a block behind another paints first, on either axis", () => { 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 right = block(3, 0, 1, 1, 1); assert.deepEqual(painted([left, right]), [left, right]); diff --git a/frontend/src/iso/engine.ts b/frontend/src/iso/engine.ts index 19a780b..c350757 100644 --- a/frontend/src/iso/engine.ts +++ b/frontend/src/iso/engine.ts @@ -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 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; + 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. + if (apartX && apartZ) return behindX && behindZ; + return apartX ? behindX : behindZ; } /** Back to front: each block after what it covers. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0c8df51..4c779e2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -71,7 +71,10 @@ main { min-height: 100vh; } /* Pages */ .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%; } +.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 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; } @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; } .foot { padding: 32px 20px; } .chapters .name { font-size: 22px; } .hero-iso { width: 100%; height: 55%; } - .hero > :not(canvas) { max-width: none; } + .hero > :not(.iso) { max-width: none; } } @media (prefers-reduced-motion: reduce) {