mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
fix: smooth donut lab
This commit is contained in:
1 parent
88197492ad
commit
7ed16725cf
5 files changed
+1075
-289
No files matched your search
@@ -2097,6 +2097,15 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
|
||||
const node = document.querySelector('[data-slot="donut-lab"]');
|
||||
return node ? { bites: Number(node.dataset.bites), state: node.dataset.state } : null;
|
||||
`);
|
||||
// Captures show the finished donut, not the reveal out of the logo.
|
||||
const labReady = () =>
|
||||
app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector('[data-slot="donut-lab"]')?.dataset.phase === "ready";`,
|
||||
),
|
||||
{ description: "the donut at rest after its reveal" },
|
||||
);
|
||||
|
||||
await openAbout();
|
||||
const system = await app.invoke("get_system_info");
|
||||
@@ -2172,6 +2181,7 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
|
||||
"the lab covers the About content",
|
||||
);
|
||||
assert.deepEqual(await lab(), { bites: 0, state: "whole" });
|
||||
await labReady();
|
||||
await app.capture("donut-lab");
|
||||
|
||||
await app.clickSelector(`${slot("donut-lab")} canvas`);
|
||||
@@ -2227,6 +2237,7 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
|
||||
await resize(app, 640, 400);
|
||||
await assertContained(app, '[role="dialog"]');
|
||||
await assertContained(app, slot("donut-lab"));
|
||||
await labReady();
|
||||
await app.capture("donut-lab-small");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +124,11 @@ export function AboutDialog({
|
||||
const [licenseQuery, setLicenseQuery] = useState("");
|
||||
const [check, setCheck] = useState<CheckState>("idle");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [lab, setLab] = useState<{ x: number; y: number } | null>(null);
|
||||
const [lab, setLab] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
} | null>(null);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -245,7 +249,11 @@ export function AboutDialog({
|
||||
clicks.length = 0;
|
||||
const rect = logo.getBoundingClientRect();
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
setLab({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
|
||||
setLab({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
size: rect.width,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Each press squeezes a little harder, as if something were inside.
|
||||
@@ -486,8 +494,7 @@ export function AboutDialog({
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border pt-3 text-[11px] text-muted-foreground">
|
||||
<p>{t("about.footer")}</p>
|
||||
<footer className="flex items-center justify-end border-t border-border pt-3 text-[11px] text-muted-foreground">
|
||||
<LinkButton onClick={() => void openUrl(REPOSITORY_URL)}>
|
||||
<LuGithub aria-hidden="true" className="size-3.5" />
|
||||
{t("about.sourceCode")}
|
||||
|
||||
+294
-245
@@ -19,24 +19,39 @@ import {
|
||||
addBite,
|
||||
type Bite,
|
||||
bakeDonut,
|
||||
type CellMetrics,
|
||||
CRUMB_GLYPHS,
|
||||
type Crumb,
|
||||
centerLens,
|
||||
crumbAlpha,
|
||||
DEFAULT_CELL,
|
||||
type Donut,
|
||||
type Frame,
|
||||
findBiteTarget,
|
||||
forEachGlyph,
|
||||
forEachRun,
|
||||
GLYPHS,
|
||||
type Grid,
|
||||
isEaten,
|
||||
KIND_CRUMB,
|
||||
KIND_DOUGH,
|
||||
KIND_EMPTY,
|
||||
KIND_GLAZE,
|
||||
KIND_SPRINKLE,
|
||||
layoutGrid,
|
||||
MAX_FLICK,
|
||||
type Mat3,
|
||||
type Momentum,
|
||||
materialAt,
|
||||
type Pose,
|
||||
REVEAL_MS,
|
||||
type RevealOrigin,
|
||||
remainingShare,
|
||||
renderFrame,
|
||||
revealPose,
|
||||
SPRINKLE_COLORS,
|
||||
START_ORIENTATION,
|
||||
spin,
|
||||
spinStep,
|
||||
stepCrumbs,
|
||||
turn,
|
||||
} from "@/lib/donut-lab";
|
||||
import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion";
|
||||
@@ -46,36 +61,15 @@ import { cn } from "@/lib/utils";
|
||||
/** Radians per pixel of drag, and per arrow key press. */
|
||||
const DRAG_TURN = 0.011;
|
||||
const KEY_TURN = 0.26;
|
||||
/** Radians per second the donut turns on its own axis when left alone. */
|
||||
const IDLE_SPIN = 0.55;
|
||||
/** How fast a flick slows down, per second. */
|
||||
const FRICTION = 2.6;
|
||||
const MAX_FLICK = 9;
|
||||
const ASSEMBLY_MS = 700;
|
||||
const CHOMP_MS = 140;
|
||||
const GRAVITY = 1100;
|
||||
const CRUMB_GLYPHS = ".,'`:;*";
|
||||
const SCRAMBLE = "@%#*+=-:.";
|
||||
const TARGET_COLS = 64;
|
||||
|
||||
type Assembly = {
|
||||
start: number;
|
||||
/** "burst": out of the logo. "rings": in from rings around the view. */
|
||||
mode: "burst" | "rings";
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
interface Crumb {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
char: string;
|
||||
kind: number;
|
||||
age: number;
|
||||
life: number;
|
||||
}
|
||||
/** The longest step the clock takes between two frames, in seconds. */
|
||||
const MAX_STEP = 0.1;
|
||||
/** Ends the reveal when frames stop coming (a paused webview). */
|
||||
const REVEAL_FALLBACK_MS = REVEAL_MS + 400;
|
||||
/** The logo's size, when the opener does not give it. */
|
||||
const LOGO_SIZE = 56;
|
||||
const MONO_FALLBACK =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace';
|
||||
|
||||
interface Palette {
|
||||
foreground: string;
|
||||
@@ -112,14 +106,46 @@ function resolvePalette(host: HTMLElement): Palette {
|
||||
return { foreground, sprinkles, ink };
|
||||
}
|
||||
|
||||
function easeOut(t: number) {
|
||||
return 1 - (1 - t) ** 3;
|
||||
/**
|
||||
* The mono font stack. The `font-mono` class does not reach it: the theme
|
||||
* reads --font-geist-mono at :root, and next/font sets it on body.
|
||||
*/
|
||||
function monoFamily(host: HTMLElement) {
|
||||
return (
|
||||
getComputedStyle(host).getPropertyValue("--font-geist-mono").trim() ||
|
||||
MONO_FALLBACK
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable pseudo-random 0..1 for a cell, changing every `tick`. */
|
||||
function flicker(index: number, tick: number) {
|
||||
const n = Math.sin(index * 12.9898 + tick * 78.233) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
/**
|
||||
* The cell of the font the canvas draws with: its widest glyph, its line, and
|
||||
* whether every glyph has the same advance.
|
||||
*/
|
||||
function measureCell(
|
||||
context: CanvasRenderingContext2D,
|
||||
family: string,
|
||||
): CellMetrics {
|
||||
context.font = `100px ${family}`;
|
||||
let widest = 0;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (const char of GLYPHS) {
|
||||
const width = context.measureText(char).width;
|
||||
widest = Math.max(widest, width);
|
||||
narrowest = Math.min(narrowest, width);
|
||||
}
|
||||
const metrics = context.measureText("M");
|
||||
const line =
|
||||
(metrics.fontBoundingBoxAscent ?? 0) +
|
||||
(metrics.fontBoundingBoxDescent ?? 0);
|
||||
return {
|
||||
advance: widest / 100 || DEFAULT_CELL.advance,
|
||||
lineHeight: line > 0 ? line / 100 : DEFAULT_CELL.lineHeight,
|
||||
mono: widest > 0 && widest - narrowest < 0.2,
|
||||
};
|
||||
}
|
||||
|
||||
function easeOut(t: number) {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
const KIND_ALPHA: Record<number, number> = {
|
||||
@@ -129,14 +155,15 @@ const KIND_ALPHA: Record<number, number> = {
|
||||
};
|
||||
|
||||
interface DonutLabProps {
|
||||
/** The logo's center in client pixels: the donut grows out of it. */
|
||||
origin: { x: number; y: number } | null;
|
||||
/** The logo's center (and width) in client pixels: the donut comes out of it. */
|
||||
origin: { x: number; y: number; size?: number } | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The About easter egg: a donut drawn in text, ray marched live. Drag to
|
||||
* turn it, click or press Space to bite it, bake another when it is gone.
|
||||
* Every motion runs on one clock that advances with frame time.
|
||||
*/
|
||||
export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -149,8 +176,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
const donutRef = useRef<Donut | null>(null);
|
||||
donutRef.current ??= bakeDonut();
|
||||
const orientationRef = useRef<Mat3>(START_ORIENTATION);
|
||||
const flickRef = useRef({ x: 0, y: 0 });
|
||||
const idleRef = useRef(1);
|
||||
const momentumRef = useRef<Momentum>({ x: 0, y: 0, idle: 1 });
|
||||
const dragRef = useRef<{
|
||||
id: number;
|
||||
x: number;
|
||||
@@ -163,17 +189,22 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
const suppressClickRef = useRef(false);
|
||||
const crumbsRef = useRef<Crumb[]>([]);
|
||||
const chompRef = useRef<{ bite: Bite; start: number } | null>(null);
|
||||
const assemblyRef = useRef<Assembly | null>(null);
|
||||
/** Milliseconds of animation so far: frame time, each step clamped. */
|
||||
const clockRef = useRef(0);
|
||||
const lastFrameRef = useRef<number | null>(null);
|
||||
const revealRef = useRef<{ start: number; origin: RevealOrigin } | null>(
|
||||
null,
|
||||
);
|
||||
const revealTimerRef = useRef(0);
|
||||
const poseRef = useRef<Pose | null>(null);
|
||||
const layoutRef = useRef<{
|
||||
grid: Grid;
|
||||
fontSize: number;
|
||||
font: string;
|
||||
dpr: number;
|
||||
mono: boolean;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<Frame | null>(null);
|
||||
const paletteRef = useRef<Palette | null>(null);
|
||||
const rafRef = useRef(0);
|
||||
const lastTickRef = useRef(0);
|
||||
const motionRef = useRef({ reducedMotion, motionAllowed });
|
||||
motionRef.current = { reducedMotion, motionAllowed };
|
||||
|
||||
@@ -181,237 +212,202 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
const [left, setLeft] = useState(100);
|
||||
const [eaten, setEaten] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [phase, setPhase] = useState<"reveal" | "ready">(() =>
|
||||
origin && motionAllowed ? "reveal" : "ready",
|
||||
);
|
||||
|
||||
const draw = useCallback((now: number) => {
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const layout = layoutRef.current;
|
||||
const palette = paletteRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !layout || !palette || !context) return;
|
||||
const { grid, font, dpr } = layout;
|
||||
const donut = donutRef.current;
|
||||
if (!donut) return;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !layout || !palette || !donut || !context) return;
|
||||
const { grid, font, mono } = layout;
|
||||
const reveal = revealRef.current;
|
||||
const pose = revealPose(
|
||||
orientationRef.current,
|
||||
grid,
|
||||
reveal?.origin ?? null,
|
||||
reveal ? clockRef.current - reveal.start : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
poseRef.current = pose;
|
||||
const frame = renderFrame(
|
||||
donut,
|
||||
orientationRef.current,
|
||||
pose.orientation,
|
||||
grid,
|
||||
palette.ink,
|
||||
frameRef.current ?? undefined,
|
||||
pose.lens,
|
||||
);
|
||||
frameRef.current = frame;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, grid.width, grid.height);
|
||||
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.globalAlpha = 1;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.setTransform(
|
||||
canvas.width / grid.width,
|
||||
0,
|
||||
0,
|
||||
canvas.height / grid.height,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
context.font = font;
|
||||
context.fontKerning = "none";
|
||||
context.textBaseline = "middle";
|
||||
context.textAlign = "left";
|
||||
const colorOf = (kind: number) =>
|
||||
kind >= KIND_SPRINKLE
|
||||
? palette.sprinkles[kind - KIND_SPRINKLE]
|
||||
: palette.foreground;
|
||||
const alphaOf = (kind: number) => KIND_ALPHA[kind] ?? 1;
|
||||
|
||||
const assembly = assemblyRef.current;
|
||||
const progress = assembly
|
||||
? Math.min(1, (now - assembly.start) / ASSEMBLY_MS)
|
||||
: 1;
|
||||
if (assembly && progress < 1) {
|
||||
// Every character flies to its place: out of the logo, or in from
|
||||
// rings around the view, still scrambled until it lands.
|
||||
const cx = grid.width / 2;
|
||||
const cy = grid.height / 2;
|
||||
const reach = Math.hypot(cx, cy);
|
||||
const tick = Math.floor(now / 45);
|
||||
for (
|
||||
let kind = KIND_DOUGH;
|
||||
kind < KIND_SPRINKLE + SPRINKLE_COLORS;
|
||||
kind++
|
||||
) {
|
||||
context.fillStyle = colorOf(kind);
|
||||
for (let i = 0; i < frame.kinds.length; i++) {
|
||||
if (frame.kinds[i] !== kind) continue;
|
||||
const col = i % grid.cols;
|
||||
const row = (i - col) / grid.cols;
|
||||
const fx = grid.offsetX + (col + 0.5) * grid.cellWidth;
|
||||
const fy = grid.offsetY + (row + 0.5) * grid.cellHeight;
|
||||
const distance = Math.hypot(fx - cx, fy - cy) / reach;
|
||||
const delay =
|
||||
(assembly.mode === "burst" ? distance : 1 - distance) * 0.35;
|
||||
const local = easeOut(
|
||||
Math.min(1, Math.max(0, (progress - delay) / 0.65)),
|
||||
);
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (assembly.mode === "burst") {
|
||||
const sx = assembly.x + (fx - assembly.x) * 0.15;
|
||||
const sy = assembly.y + (fy - assembly.y) * 0.15;
|
||||
x = sx + (fx - sx) * local;
|
||||
y = sy + (fy - sy) * local;
|
||||
} else {
|
||||
const angle = Math.atan2(fy - cy, fx - cx) + 0.9 * (1 - local);
|
||||
const radius =
|
||||
Math.hypot(fx - cx, fy - cy) + reach * 0.6 * (1 - local);
|
||||
x = cx + Math.cos(angle) * radius;
|
||||
y = cy + Math.sin(angle) * radius;
|
||||
}
|
||||
const char =
|
||||
local < 0.75
|
||||
? SCRAMBLE[Math.floor(flicker(i, tick) * SCRAMBLE.length)]
|
||||
: frame.chars[i];
|
||||
context.globalAlpha = alphaOf(kind) * (0.55 + 0.45 * local);
|
||||
context.fillText(char, x - grid.cellWidth / 2, y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (assembly) assemblyRef.current = null;
|
||||
// One string per row and color: spaces keep the columns.
|
||||
const rowChars = Array.from(
|
||||
{ length: KIND_SPRINKLE + SPRINKLE_COLORS },
|
||||
() => new Array<string>(grid.cols),
|
||||
);
|
||||
const used = new Array<boolean>(KIND_SPRINKLE + SPRINKLE_COLORS);
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
used.fill(false);
|
||||
for (const chars of rowChars) chars.fill(" ");
|
||||
for (let col = 0; col < grid.cols; col++) {
|
||||
const i = row * grid.cols + col;
|
||||
const kind = frame.kinds[i];
|
||||
if (kind === KIND_EMPTY) continue;
|
||||
rowChars[kind][col] = frame.chars[i];
|
||||
used[kind] = true;
|
||||
}
|
||||
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
|
||||
for (let kind = KIND_DOUGH; kind < rowChars.length; kind++) {
|
||||
if (!used[kind]) continue;
|
||||
context.fillStyle = colorOf(kind);
|
||||
context.globalAlpha = alphaOf(kind);
|
||||
context.fillText(rowChars[kind].join(""), grid.offsetX, y);
|
||||
}
|
||||
}
|
||||
// Glyphs stay in their cells: a monospace font draws runs of cells from
|
||||
// their left edge, any other font draws each glyph at its cell's center.
|
||||
context.textAlign = mono ? "left" : "center";
|
||||
const fill = (text: string, x: number, y: number) => {
|
||||
context.fillText(text, x, y);
|
||||
};
|
||||
for (
|
||||
let kind = KIND_DOUGH;
|
||||
kind < KIND_SPRINKLE + SPRINKLE_COLORS;
|
||||
kind++
|
||||
) {
|
||||
context.fillStyle = colorOf(kind);
|
||||
context.globalAlpha = KIND_ALPHA[kind] ?? 1;
|
||||
if (mono) forEachRun(frame, grid, fill, kind);
|
||||
else forEachGlyph(frame, grid, fill, kind);
|
||||
}
|
||||
|
||||
context.textAlign = "center";
|
||||
for (const crumb of crumbsRef.current) {
|
||||
context.fillStyle = colorOf(crumb.kind);
|
||||
context.globalAlpha =
|
||||
alphaOf(crumb.kind) * Math.min(1, (crumb.life - crumb.age) / 0.4);
|
||||
context.globalAlpha = (KIND_ALPHA[crumb.kind] ?? 1) * crumbAlpha(crumb);
|
||||
context.fillText(crumb.char, crumb.x, crumb.y);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}, []);
|
||||
|
||||
const finishReveal = useCallback(() => {
|
||||
window.clearTimeout(revealTimerRef.current);
|
||||
if (!revealRef.current) return;
|
||||
revealRef.current = null;
|
||||
setPhase("ready");
|
||||
}, []);
|
||||
|
||||
const needsFrames = useCallback(() => {
|
||||
const flick = flickRef.current;
|
||||
const momentum = momentumRef.current;
|
||||
return (
|
||||
!motionRef.current.reducedMotion ||
|
||||
Math.abs(flick.x) + Math.abs(flick.y) > 0.01 ||
|
||||
momentum.x !== 0 ||
|
||||
momentum.y !== 0 ||
|
||||
crumbsRef.current.length > 0 ||
|
||||
chompRef.current !== null ||
|
||||
assemblyRef.current !== null
|
||||
revealRef.current !== null
|
||||
);
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(
|
||||
(now: number) => {
|
||||
rafRef.current = 0;
|
||||
const dt = Math.min(
|
||||
0.05,
|
||||
Math.max(0, (now - lastTickRef.current) / 1000),
|
||||
);
|
||||
lastTickRef.current = now;
|
||||
const { reducedMotion: reduced } = motionRef.current;
|
||||
const last = lastFrameRef.current;
|
||||
lastFrameRef.current = now;
|
||||
const dt =
|
||||
last === null
|
||||
? 0
|
||||
: Math.min(MAX_STEP, Math.max(0, (now - last) / 1000));
|
||||
clockRef.current += dt * 1000;
|
||||
const clock = clockRef.current;
|
||||
|
||||
if (!dragRef.current?.moved) {
|
||||
const flick = flickRef.current;
|
||||
let m = orientationRef.current;
|
||||
if (Math.abs(flick.x) + Math.abs(flick.y) > 0.01) {
|
||||
m = turn(m, flick.x * dt, flick.y * dt);
|
||||
const decay = Math.exp(-FRICTION * dt);
|
||||
flick.x *= decay;
|
||||
flick.y *= decay;
|
||||
} else {
|
||||
flick.x = 0;
|
||||
flick.y = 0;
|
||||
}
|
||||
if (!reduced) {
|
||||
idleRef.current = Math.min(1, idleRef.current + dt * 0.8);
|
||||
m = spin(m, IDLE_SPIN * idleRef.current * dt);
|
||||
}
|
||||
orientationRef.current = m;
|
||||
orientationRef.current = spinStep(
|
||||
orientationRef.current,
|
||||
momentumRef.current,
|
||||
dt,
|
||||
!motionRef.current.reducedMotion,
|
||||
);
|
||||
}
|
||||
|
||||
const reveal = revealRef.current;
|
||||
if (reveal && clock - reveal.start >= REVEAL_MS) finishReveal();
|
||||
|
||||
const chomp = chompRef.current;
|
||||
if (chomp) {
|
||||
const p = Math.min(1, (now - chomp.start) / CHOMP_MS);
|
||||
const p = Math.min(1, (clock - chomp.start) / CHOMP_MS);
|
||||
chomp.bite.grow = 0.35 + 0.65 * easeOut(p);
|
||||
if (p >= 1) chompRef.current = null;
|
||||
}
|
||||
|
||||
const layout = layoutRef.current;
|
||||
if (layout && crumbsRef.current.length > 0) {
|
||||
const floor = layout.grid.height - layout.grid.cellHeight * 0.6;
|
||||
crumbsRef.current = crumbsRef.current.filter((crumb) => {
|
||||
crumb.age += dt;
|
||||
crumb.vy += GRAVITY * dt;
|
||||
crumb.x += crumb.vx * dt;
|
||||
crumb.y += crumb.vy * dt;
|
||||
if (crumb.y > floor) {
|
||||
crumb.y = floor;
|
||||
crumb.vy = Math.abs(crumb.vy) < 60 ? 0 : -crumb.vy * 0.3;
|
||||
crumb.vx *= 0.55;
|
||||
}
|
||||
return crumb.age < crumb.life;
|
||||
});
|
||||
crumbsRef.current = stepCrumbs(
|
||||
crumbsRef.current,
|
||||
dt,
|
||||
layout.grid.height - layout.grid.cellHeight * 0.6,
|
||||
);
|
||||
}
|
||||
|
||||
draw(now);
|
||||
draw();
|
||||
if (document.visibilityState === "visible" && needsFrames()) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
},
|
||||
[draw, needsFrames],
|
||||
[draw, finishReveal, needsFrames],
|
||||
);
|
||||
|
||||
/** Draws now, and keeps drawing while something moves. */
|
||||
const wake = useCallback(() => {
|
||||
const now = performance.now();
|
||||
draw(now);
|
||||
draw();
|
||||
if (
|
||||
!rafRef.current &&
|
||||
document.visibilityState === "visible" &&
|
||||
needsFrames()
|
||||
) {
|
||||
lastTickRef.current = now;
|
||||
lastFrameRef.current = null;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [draw, needsFrames, tick]);
|
||||
|
||||
// Size the canvas to its box and lay the character grid over it.
|
||||
/** Starts the donut at `start` (canvas pixels), or at rest when motion is off. */
|
||||
const startReveal = useCallback(
|
||||
(start: RevealOrigin | null) => {
|
||||
window.clearTimeout(revealTimerRef.current);
|
||||
if (!start || !motionRef.current.motionAllowed) {
|
||||
revealRef.current = null;
|
||||
setPhase("ready");
|
||||
return;
|
||||
}
|
||||
revealRef.current = { start: clockRef.current, origin: start };
|
||||
setPhase("reveal");
|
||||
revealTimerRef.current = window.setTimeout(() => {
|
||||
finishReveal();
|
||||
draw();
|
||||
}, REVEAL_FALLBACK_MS);
|
||||
},
|
||||
[draw, finishReveal],
|
||||
);
|
||||
|
||||
// Size the canvas to its box, in device pixels, and lay the grid over it
|
||||
// again whenever the box, the pixel ratio or the font changes.
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!canvas || !host) return;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !host || !context) return;
|
||||
paletteRef.current = resolvePalette(host);
|
||||
const family = getComputedStyle(canvas).fontFamily || "monospace";
|
||||
const measure = canvas.getContext("2d");
|
||||
if (measure) measure.font = `100px ${family}`;
|
||||
let advance = (measure?.measureText("M").width ?? 60) / 100;
|
||||
const family = monoFamily(host);
|
||||
let cell = measureCell(context, family);
|
||||
const resize = () => {
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
if (width === 0 || height === 0) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.round(width * dpr);
|
||||
canvas.height = Math.round(height * dpr);
|
||||
const { grid, fontSize } = layoutGrid(
|
||||
width,
|
||||
height,
|
||||
advance,
|
||||
TARGET_COLS,
|
||||
);
|
||||
const backingWidth = Math.max(1, Math.round(width * dpr));
|
||||
const backingHeight = Math.max(1, Math.round(height * dpr));
|
||||
if (canvas.width !== backingWidth) canvas.width = backingWidth;
|
||||
if (canvas.height !== backingHeight) canvas.height = backingHeight;
|
||||
const { grid, fontSize } = layoutGrid(width, height, cell);
|
||||
layoutRef.current = {
|
||||
grid,
|
||||
fontSize,
|
||||
font: `${fontSize}px ${family}`,
|
||||
dpr,
|
||||
mono: cell.mono === true,
|
||||
};
|
||||
frameRef.current = null;
|
||||
wake();
|
||||
@@ -419,33 +415,59 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
// Measure again once the mono font is in, if it was not yet.
|
||||
let alive = true;
|
||||
void document.fonts?.load(`16px ${family}`).then(() => {
|
||||
if (!alive || !measure) return;
|
||||
measure.font = `100px ${family}`;
|
||||
advance = measure.measureText("M").width / 100 || advance;
|
||||
|
||||
let media: MediaQueryList | null = null;
|
||||
const onPixelRatio = () => {
|
||||
watchPixelRatio();
|
||||
resize();
|
||||
});
|
||||
};
|
||||
const watchPixelRatio = () => {
|
||||
media?.removeEventListener("change", onPixelRatio);
|
||||
media =
|
||||
window.matchMedia?.(
|
||||
`(resolution: ${window.devicePixelRatio || 1}dppx)`,
|
||||
) ?? null;
|
||||
media?.addEventListener("change", onPixelRatio);
|
||||
};
|
||||
watchPixelRatio();
|
||||
|
||||
// The mono font may load after the first frame: measure it again then.
|
||||
let alive = true;
|
||||
const remeasure = () => {
|
||||
if (!alive) return;
|
||||
cell = measureCell(context, family);
|
||||
resize();
|
||||
};
|
||||
void document.fonts?.load(`16px ${family}`, GLYPHS).then(remeasure);
|
||||
document.fonts?.addEventListener("loadingdone", remeasure);
|
||||
return () => {
|
||||
alive = false;
|
||||
observer.disconnect();
|
||||
media?.removeEventListener("change", onPixelRatio);
|
||||
document.fonts?.removeEventListener("loadingdone", remeasure);
|
||||
};
|
||||
}, [wake]);
|
||||
|
||||
// The donut takes the keys, and grows out of the logo the first time.
|
||||
// The donut takes the keys, and comes out of the logo the first time.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: once, on open
|
||||
useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
canvas?.focus({ preventScroll: true });
|
||||
if (!canvas || !origin || !motionRef.current.motionAllowed) return;
|
||||
if (!canvas || !origin) {
|
||||
startReveal(null);
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
// The box may be scaled by an entry animation: map client pixels to its own.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
assemblyRef.current = {
|
||||
start: performance.now(),
|
||||
mode: "burst",
|
||||
x: origin.x - rect.left,
|
||||
y: origin.y - rect.top,
|
||||
};
|
||||
const scale = rect.width > 0 ? canvas.clientWidth / rect.width : 1;
|
||||
const clamp = (value: number, max: number) =>
|
||||
Math.min(max, Math.max(0, value));
|
||||
startReveal({
|
||||
x: clamp((origin.x - rect.left) * scale, canvas.clientWidth),
|
||||
y: clamp((origin.y - rect.top) * scale, canvas.clientHeight),
|
||||
size: (origin.size ?? LOGO_SIZE) * scale,
|
||||
});
|
||||
wake();
|
||||
}, []);
|
||||
|
||||
@@ -462,30 +484,43 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
window.clearTimeout(revealTimerRef.current);
|
||||
};
|
||||
}, [wake]);
|
||||
|
||||
// Reduced motion stops the idle spin; a change of setting restarts frames.
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
flickRef.current = { x: 0, y: 0 };
|
||||
momentumRef.current = { x: 0, y: 0, idle: 0 };
|
||||
crumbsRef.current = [];
|
||||
}
|
||||
wake();
|
||||
}, [reducedMotion, wake]);
|
||||
|
||||
/** Where the donut is drawn now, so a press bites what it is on. */
|
||||
const currentPose = useCallback((grid: Grid): Pose => {
|
||||
return (
|
||||
poseRef.current ?? {
|
||||
orientation: orientationRef.current,
|
||||
lens: centerLens(grid),
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const takeBite = useCallback(
|
||||
(x: number, y: number, reach: number) => {
|
||||
const layout = layoutRef.current;
|
||||
const donut = donutRef.current;
|
||||
if (!layout || !donut || isEaten(donut)) return;
|
||||
const pose = currentPose(layout.grid);
|
||||
const target = findBiteTarget(
|
||||
donut,
|
||||
orientationRef.current,
|
||||
pose.orientation,
|
||||
layout.grid,
|
||||
x,
|
||||
y,
|
||||
reach,
|
||||
pose.lens,
|
||||
);
|
||||
if (!target) return;
|
||||
const { material, sprinkle } = materialAt(donut, target.point);
|
||||
@@ -498,7 +533,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
);
|
||||
playUiSound("crunch");
|
||||
if (animate) {
|
||||
chompRef.current = { bite, start: performance.now() };
|
||||
chompRef.current = { bite, start: clockRef.current };
|
||||
const kind =
|
||||
material === "glaze"
|
||||
? KIND_GLAZE
|
||||
@@ -524,8 +559,8 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
});
|
||||
}
|
||||
// The donut rocks a little under the bite.
|
||||
flickRef.current.x += (Math.random() - 0.5) * 1.2;
|
||||
flickRef.current.y += (Math.random() - 0.5) * 1.2;
|
||||
momentumRef.current.x += (Math.random() - 0.5) * 1.2;
|
||||
momentumRef.current.y += (Math.random() - 0.5) * 1.2;
|
||||
}
|
||||
const share = remainingShare(donut);
|
||||
setBites(donut.bites.length);
|
||||
@@ -533,34 +568,42 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
setEaten(isEaten(donut, share));
|
||||
wake();
|
||||
},
|
||||
[wake],
|
||||
[currentPose, wake],
|
||||
);
|
||||
|
||||
const biteNearCenter = useCallback(() => {
|
||||
const layout = layoutRef.current;
|
||||
if (!layout) return;
|
||||
const { lens } = currentPose(layout.grid);
|
||||
const { width, height } = layout.grid;
|
||||
takeBite(width / 2, height / 2, Math.max(width, height));
|
||||
}, [takeBite]);
|
||||
takeBite(lens.x, lens.y, Math.max(width, height));
|
||||
}, [currentPose, takeBite]);
|
||||
|
||||
const bakeAnother = useCallback(() => {
|
||||
donutRef.current = bakeDonut();
|
||||
chompRef.current = null;
|
||||
crumbsRef.current = [];
|
||||
setBites(0);
|
||||
setLeft(100);
|
||||
setEaten(false);
|
||||
const layout = layoutRef.current;
|
||||
if (layout && motionRef.current.motionAllowed) {
|
||||
assemblyRef.current = {
|
||||
start: performance.now(),
|
||||
mode: "rings",
|
||||
x: layout.grid.width / 2,
|
||||
y: layout.grid.height / 2,
|
||||
};
|
||||
}
|
||||
// The new donut grows out of the middle.
|
||||
startReveal(
|
||||
layout
|
||||
? { x: layout.grid.width / 2, y: layout.grid.height / 2, size: 0 }
|
||||
: null,
|
||||
);
|
||||
canvasRef.current?.focus();
|
||||
wake();
|
||||
}, [wake]);
|
||||
}, [startReveal, wake]);
|
||||
|
||||
/** A pointer position in the canvas's own pixels, whatever its transform. */
|
||||
const canvasPoint = (canvas: HTMLCanvasElement, x: number, y: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = rect.width > 0 ? canvas.clientWidth / rect.width : 1;
|
||||
const scaleY = rect.height > 0 ? canvas.clientHeight / rect.height : 1;
|
||||
return { x: (x - rect.left) * scaleX, y: (y - rect.top) * scaleY };
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
if (event.button !== 0 || dragRef.current) return;
|
||||
@@ -579,7 +622,6 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
moved: false,
|
||||
};
|
||||
suppressClickRef.current = false;
|
||||
flickRef.current = { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
@@ -591,9 +633,14 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const momentum = momentumRef.current;
|
||||
if (!drag.moved) {
|
||||
drag.moved = true;
|
||||
setDragging(true);
|
||||
// The hand holds the donut: no flick, no idle spin, until it lets go.
|
||||
momentum.x = 0;
|
||||
momentum.y = 0;
|
||||
momentum.idle = 0;
|
||||
}
|
||||
const dx = event.clientX - drag.x;
|
||||
const dy = event.clientY - drag.y;
|
||||
@@ -607,9 +654,8 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
dx * DRAG_TURN,
|
||||
);
|
||||
// The flick is the drag's recent speed, smoothed.
|
||||
const flick = flickRef.current;
|
||||
flick.x = flick.x * 0.6 + ((dy * DRAG_TURN) / dt) * 0.4;
|
||||
flick.y = flick.y * 0.6 + ((dx * DRAG_TURN) / dt) * 0.4;
|
||||
momentum.x = momentum.x * 0.6 + ((dy * DRAG_TURN) / dt) * 0.4;
|
||||
momentum.y = momentum.y * 0.6 + ((dx * DRAG_TURN) / dt) * 0.4;
|
||||
wake();
|
||||
};
|
||||
|
||||
@@ -620,16 +666,15 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
if (!drag.moved) return;
|
||||
suppressClickRef.current = true;
|
||||
setDragging(false);
|
||||
idleRef.current = 0;
|
||||
const flick = flickRef.current;
|
||||
const momentum = momentumRef.current;
|
||||
// A drag that stopped before release does not fling.
|
||||
const still = event.timeStamp - drag.at > 80;
|
||||
if (motionRef.current.reducedMotion || still) {
|
||||
flick.x = 0;
|
||||
flick.y = 0;
|
||||
momentum.x = 0;
|
||||
momentum.y = 0;
|
||||
} else {
|
||||
flick.x = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.x));
|
||||
flick.y = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.y));
|
||||
momentum.x = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, momentum.x));
|
||||
momentum.y = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, momentum.y));
|
||||
}
|
||||
wake();
|
||||
};
|
||||
@@ -644,8 +689,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
const step = turns[event.key];
|
||||
if (step) {
|
||||
event.preventDefault();
|
||||
flickRef.current = { x: 0, y: 0 };
|
||||
idleRef.current = 0;
|
||||
momentumRef.current = { x: 0, y: 0, idle: 0 };
|
||||
orientationRef.current = turn(orientationRef.current, ...step);
|
||||
wake();
|
||||
return;
|
||||
@@ -667,11 +711,12 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
data-slot="donut-lab"
|
||||
data-bites={bites}
|
||||
data-state={eaten ? "eaten" : "whole"}
|
||||
data-phase={phase}
|
||||
// Readable from the first frame: the entry only settles its scale.
|
||||
initial={motionAllowed ? { scale: 0.985 } : false}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: MOTION_DURATION.base, ease: MOTION_EASE_OUT }}
|
||||
className="absolute inset-0 z-20 overflow-hidden rounded-[inherit] bg-background bg-[radial-gradient(circle_at_50%_48%,color-mix(in_oklab,var(--foreground)_7%,transparent),transparent_62%)]"
|
||||
className="absolute inset-0 z-20 overflow-hidden rounded-[inherit] bg-background bg-[radial-gradient(circle_at_50%_50%,color-mix(in_oklab,var(--foreground)_7%,transparent),transparent_62%)]"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
@@ -694,16 +739,20 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
|
||||
biteNearCenter();
|
||||
return;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const point = canvasPoint(
|
||||
event.currentTarget,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
takeBite(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
point.x,
|
||||
point.y,
|
||||
Math.min(layout.grid.width, layout.grid.height) * 0.22,
|
||||
);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"absolute inset-0 size-full touch-none font-mono outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"absolute inset-0 size-full touch-none outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
dragging ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
/>
|
||||
|
||||
+371
-5
@@ -3,32 +3,52 @@ import test from "node:test";
|
||||
import {
|
||||
addBite,
|
||||
bakeDonut,
|
||||
CRUMB_GLYPHS,
|
||||
CRUMB_RAMP,
|
||||
castRay,
|
||||
centerLens,
|
||||
crumbAlpha,
|
||||
DEFAULT_CELL,
|
||||
DONUT_SPAN,
|
||||
DOUGH_RAMP,
|
||||
donutDistance,
|
||||
findBiteTarget,
|
||||
forEachGlyph,
|
||||
forEachRun,
|
||||
GLAZE_RAMP,
|
||||
GLYPHS,
|
||||
identity,
|
||||
idleOrientation,
|
||||
isEaten,
|
||||
KIND_DOUGH,
|
||||
KIND_EMPTY,
|
||||
KIND_GLAZE,
|
||||
KIND_SPRINKLE,
|
||||
layoutGrid,
|
||||
MAJOR_RADIUS,
|
||||
MAX_BITES,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
MINOR_RADIUS,
|
||||
materialAt,
|
||||
normalAt,
|
||||
REVEAL_MS,
|
||||
rampFor,
|
||||
remainingShare,
|
||||
renderFrame,
|
||||
revealPose,
|
||||
rotationX,
|
||||
START_ORIENTATION,
|
||||
seededRandom,
|
||||
shade,
|
||||
spin,
|
||||
spinStep,
|
||||
sprinkleGlyph,
|
||||
stepCrumbs,
|
||||
surfaceAt,
|
||||
toObject,
|
||||
turn,
|
||||
viewRay,
|
||||
} from "./donut-lab.ts";
|
||||
|
||||
const VIEW = { width: 600, height: 340 };
|
||||
@@ -200,19 +220,365 @@ test("turns and spins stay rotations", () => {
|
||||
test("a frame shows glaze, dough and sprinkles around an empty hole", () => {
|
||||
const donut = bakeDonut();
|
||||
const { grid, fontSize } = layoutGrid(VIEW.width, VIEW.height);
|
||||
assert.equal(grid.cols, 64);
|
||||
assert.ok(grid.rows >= 20 && grid.rows <= 40);
|
||||
assert.ok(fontSize > 8);
|
||||
assert.ok(grid.cols >= 48 && grid.rows >= 20);
|
||||
assert.ok(fontSize >= MIN_FONT_SIZE && fontSize <= MAX_FONT_SIZE);
|
||||
const frame = renderFrame(donut, START_ORIENTATION, grid, false);
|
||||
const count = (predicate) => frame.kinds.filter(predicate).length;
|
||||
assert.ok(count((kind) => kind === KIND_GLAZE) > 100);
|
||||
assert.ok(count((kind) => kind === KIND_DOUGH) > 10);
|
||||
assert.ok(count((kind) => kind >= KIND_SPRINKLE) > 10);
|
||||
const middle =
|
||||
Math.floor(grid.rows / 2) * grid.cols + Math.floor(grid.cols / 2);
|
||||
const middle = cellAt(grid, grid.width / 2, grid.height / 2);
|
||||
assert.equal(frame.kinds[middle], KIND_EMPTY);
|
||||
assert.equal(frame.chars[middle], " ");
|
||||
for (const [i, char] of frame.chars.entries()) {
|
||||
assert.equal(char === " ", frame.kinds[i] === KIND_EMPTY);
|
||||
}
|
||||
});
|
||||
|
||||
// The canvas sizes the lab from these: a narrow dialog and a wide one.
|
||||
const GRIDS = {
|
||||
narrow: { width: 300, height: 220 },
|
||||
wide: { width: 640, height: 300 },
|
||||
};
|
||||
const GEIST = { advance: 0.6, lineHeight: 1.3, mono: true };
|
||||
const TALL = { advance: 0.5, lineHeight: 1.7, mono: true };
|
||||
|
||||
function cellAt(grid, x, y) {
|
||||
const col = Math.floor((x - grid.offsetX) / grid.cellWidth);
|
||||
const row = Math.floor((y - grid.offsetY) / grid.cellHeight);
|
||||
return row * grid.cols + col;
|
||||
}
|
||||
|
||||
/** Where the canvas draws each glyph: the center of its cell, in pixels. */
|
||||
function glyphs(frame, grid) {
|
||||
const out = [];
|
||||
forEachGlyph(frame, grid, (char, x, y, kind) =>
|
||||
out.push({ char, x, y, kind }),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The smallest distance from a glyph's ray to the torus's core circle. */
|
||||
function rayToRing(m, grid, lens, x, y) {
|
||||
const ray = viewRay(grid, x, y, lens);
|
||||
const origin = toObject(m, ray.origin);
|
||||
const dir = toObject(m, ray.dir);
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (let t = 3; t <= 11; t += 0.004) {
|
||||
const px = origin[0] + dir[0] * t;
|
||||
const py = origin[1] + dir[1] * t;
|
||||
const pz = origin[2] + dir[2] * t;
|
||||
const q = Math.hypot(px, pz) - MAJOR_RADIUS;
|
||||
best = Math.min(best, Math.hypot(q, py));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
test("the grid is whole cells, centered on the canvas, sized from the measured cell", () => {
|
||||
for (const view of [...Object.values(GRIDS), { width: 1360, height: 873 }]) {
|
||||
for (const cell of [GEIST, TALL, { advance: 0.92, lineHeight: 1.2 }]) {
|
||||
const { grid, fontSize } = layoutGrid(view.width, view.height, cell);
|
||||
assert.ok(fontSize >= MIN_FONT_SIZE && fontSize <= MAX_FONT_SIZE);
|
||||
assert.ok(Math.abs(grid.cellWidth - fontSize * cell.advance) < 1e-9);
|
||||
assert.ok(grid.offsetX >= 0 && grid.offsetX < grid.cellWidth / 2 + 1e-9);
|
||||
assert.ok(grid.offsetY >= 0 && grid.offsetY < grid.cellHeight / 2 + 1e-9);
|
||||
assert.ok(
|
||||
Math.abs(2 * grid.offsetX + grid.cols * grid.cellWidth - view.width) <
|
||||
1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(2 * grid.offsetY + grid.rows * grid.cellHeight - view.height) <
|
||||
1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
// A font that failed to load or measure falls back to the default cell.
|
||||
const broken = layoutGrid(600, 340, { advance: 0.08, lineHeight: 0 });
|
||||
const plain = layoutGrid(600, 340, DEFAULT_CELL);
|
||||
assert.deepEqual(broken, plain);
|
||||
});
|
||||
|
||||
test("the donut is drawn in the middle of the canvas at every size and moment", () => {
|
||||
const donut = bakeDonut();
|
||||
for (const view of Object.values(GRIDS)) {
|
||||
for (const cell of [GEIST, TALL]) {
|
||||
const { grid } = layoutGrid(view.width, view.height, cell);
|
||||
const span = DONUT_SPAN * Math.min(view.width, view.height);
|
||||
for (let t = 0; t <= 9; t += 0.37) {
|
||||
const drawn = glyphs(
|
||||
renderFrame(donut, idleOrientation(t), grid, false),
|
||||
grid,
|
||||
);
|
||||
assert.ok(drawn.length > 150, `a whole donut at t=${t}`);
|
||||
const cx = drawn.reduce((sum, g) => sum + g.x, 0) / drawn.length;
|
||||
const cy = drawn.reduce((sum, g) => sum + g.y, 0) / drawn.length;
|
||||
// Perspective makes the near side a little bigger, nothing more.
|
||||
const off = Math.hypot(cx - view.width / 2, cy - view.height / 2);
|
||||
assert.ok(
|
||||
off < span * 0.07,
|
||||
`centroid ${off.toFixed(1)} px off at t=${t}`,
|
||||
);
|
||||
for (const g of drawn) {
|
||||
assert.ok(
|
||||
g.x > 0 && g.x < view.width && g.y > 0 && g.y < view.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a face-on donut is round: its cell box has the shape of the measured cell", () => {
|
||||
const donut = bakeDonut();
|
||||
const faceOn = rotationX(Math.PI / 2);
|
||||
for (const view of Object.values(GRIDS)) {
|
||||
for (const cell of [GEIST, TALL]) {
|
||||
const { grid } = layoutGrid(view.width, view.height, cell);
|
||||
const frame = renderFrame(donut, faceOn, grid, false);
|
||||
let minCol = Infinity,
|
||||
maxCol = -Infinity,
|
||||
minRow = Infinity,
|
||||
maxRow = -Infinity;
|
||||
for (const [i, kind] of frame.kinds.entries()) {
|
||||
if (kind === KIND_EMPTY) continue;
|
||||
const col = i % grid.cols;
|
||||
const row = (i - col) / grid.cols;
|
||||
minCol = Math.min(minCol, col);
|
||||
maxCol = Math.max(maxCol, col);
|
||||
minRow = Math.min(minRow, row);
|
||||
maxRow = Math.max(maxRow, row);
|
||||
}
|
||||
const cols = maxCol - minCol + 1;
|
||||
const rows = maxRow - minRow + 1;
|
||||
const aspect = grid.cellHeight / grid.cellWidth;
|
||||
assert.ok(
|
||||
Math.abs(cols / rows - aspect) / aspect < 0.08,
|
||||
`${cols}x${rows} cells, cell aspect ${aspect.toFixed(2)}`,
|
||||
);
|
||||
// The same in pixels: as wide as it is tall, to about one cell.
|
||||
assert.ok(
|
||||
Math.abs(cols * grid.cellWidth - rows * grid.cellHeight) <=
|
||||
1.5 * Math.max(grid.cellWidth, grid.cellHeight),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("no glyph lies outside the donut, and the donut has no gaps", () => {
|
||||
const donut = bakeDonut();
|
||||
for (const view of Object.values(GRIDS)) {
|
||||
const { grid } = layoutGrid(view.width, view.height, GEIST);
|
||||
const lens = centerLens(grid);
|
||||
for (const t of [0, 0.8, 2.1, 3.5, 5, 7.3]) {
|
||||
const m = idleOrientation(t);
|
||||
const frame = renderFrame(donut, m, grid, false);
|
||||
for (const g of glyphs(frame, grid)) {
|
||||
// Tube, glaze and a sprinkle's height, and a ray's graze.
|
||||
const reach = rayToRing(m, grid, lens, g.x, g.y);
|
||||
assert.ok(
|
||||
reach < MINOR_RADIUS + 0.2,
|
||||
`'${g.char}' ${reach.toFixed(2)} off the ring`,
|
||||
);
|
||||
}
|
||||
// Every cell whose ray goes well inside the tube is drawn.
|
||||
for (let i = 0; i < frame.kinds.length; i++) {
|
||||
const col = i % grid.cols;
|
||||
const row = (i - col) / grid.cols;
|
||||
const x = grid.offsetX + (col + 0.5) * grid.cellWidth;
|
||||
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
|
||||
if (rayToRing(m, grid, lens, x, y) < MINOR_RADIUS - 0.08) {
|
||||
assert.notEqual(frame.kinds[i], KIND_EMPTY, `a gap at ${col},${row}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("runs of a monospace font put every glyph in its own cell", () => {
|
||||
const donut = bakeDonut();
|
||||
const { grid } = layoutGrid(GRIDS.wide.width, GRIDS.wide.height, GEIST);
|
||||
const frame = renderFrame(donut, idleOrientation(1.3), grid, false);
|
||||
const fromRuns = [];
|
||||
forEachRun(frame, grid, (text, x, y, kind) => {
|
||||
[...text].forEach((char, j) => {
|
||||
fromRuns.push({ char, x: x + (j + 0.5) * grid.cellWidth, y, kind });
|
||||
});
|
||||
});
|
||||
const sort = (list) => list.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const expected = sort(glyphs(frame, grid));
|
||||
const actual = sort(fromRuns);
|
||||
assert.equal(actual.length, expected.length);
|
||||
for (const [i, g] of actual.entries()) {
|
||||
assert.equal(g.char, expected[i].char);
|
||||
assert.equal(g.kind, expected[i].kind);
|
||||
assert.ok(Math.abs(g.x - expected[i].x) < 1e-6 && g.y === expected[i].y);
|
||||
}
|
||||
});
|
||||
|
||||
test("the reveal comes out of the logo and ends exactly on the frame at rest", () => {
|
||||
const donut = bakeDonut();
|
||||
const origin = { x: 40, y: 36, size: 56 };
|
||||
for (const view of Object.values(GRIDS)) {
|
||||
const { grid } = layoutGrid(view.width, view.height, GEIST);
|
||||
const m = idleOrientation(REVEAL_MS / 1000);
|
||||
const rest = renderFrame(donut, m, grid, false);
|
||||
for (const elapsed of [REVEAL_MS, REVEAL_MS + 1, 10_000]) {
|
||||
const pose = revealPose(m, grid, origin, elapsed);
|
||||
assert.deepEqual(pose.lens, centerLens(grid));
|
||||
const frame = renderFrame(
|
||||
donut,
|
||||
pose.orientation,
|
||||
grid,
|
||||
false,
|
||||
undefined,
|
||||
pose.lens,
|
||||
);
|
||||
assert.deepEqual(frame.chars, rest.chars);
|
||||
assert.deepEqual(frame.kinds, rest.kinds);
|
||||
}
|
||||
assert.deepEqual(revealPose(m, grid, null, 0).lens, centerLens(grid));
|
||||
|
||||
// The first frame is already a donut, small, on the logo.
|
||||
const first = revealPose(m, grid, origin, 0);
|
||||
const start = glyphs(
|
||||
renderFrame(donut, first.orientation, grid, false, undefined, first.lens),
|
||||
grid,
|
||||
);
|
||||
assert.ok(start.length > 8 && start.length < glyphs(rest, grid).length / 4);
|
||||
const cx = start.reduce((sum, g) => sum + g.x, 0) / start.length;
|
||||
const cy = start.reduce((sum, g) => sum + g.y, 0) / start.length;
|
||||
assert.ok(Math.hypot(cx - origin.x, cy - origin.y) < 30);
|
||||
|
||||
// On the way it only grows and moves toward the middle.
|
||||
let zoom = 0;
|
||||
let distance = Infinity;
|
||||
for (let elapsed = 0; elapsed < REVEAL_MS; elapsed += 20) {
|
||||
const { lens } = revealPose(m, grid, origin, elapsed);
|
||||
const d = Math.hypot(lens.x - grid.width / 2, lens.y - grid.height / 2);
|
||||
assert.ok(lens.zoom >= zoom && d <= distance + 1e-9);
|
||||
zoom = lens.zoom;
|
||||
distance = d;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the idle spin turns the same at any frame rate", () => {
|
||||
for (const fps of [144, 60, 23, 7]) {
|
||||
let m = START_ORIENTATION;
|
||||
const momentum = { x: 0, y: 0, idle: 1 };
|
||||
const steps = Math.round(3 * fps);
|
||||
for (let i = 0; i < steps; i++) m = spinStep(m, momentum, 1 / fps);
|
||||
const expected = idleOrientation(steps / fps);
|
||||
for (let i = 0; i < 9; i++) assert.ok(Math.abs(m[i] - expected[i]) < 1e-9);
|
||||
}
|
||||
// A constant angular speed: equal times, equal turns.
|
||||
const angle = (a, b) =>
|
||||
Math.acos(
|
||||
Math.min(
|
||||
1,
|
||||
(a[0] * b[0] +
|
||||
a[1] * b[1] +
|
||||
a[2] * b[2] +
|
||||
a[3] * b[3] +
|
||||
a[4] * b[4] +
|
||||
a[5] * b[5] +
|
||||
a[6] * b[6] +
|
||||
a[7] * b[7] +
|
||||
a[8] * b[8] -
|
||||
1) /
|
||||
2,
|
||||
),
|
||||
);
|
||||
const turns = [0, 1.5, 3, 4.5].map((t) =>
|
||||
angle(idleOrientation(t), idleOrientation(t + 0.1)),
|
||||
);
|
||||
for (const value of turns) assert.ok(Math.abs(value - turns[0]) < 0.02);
|
||||
});
|
||||
|
||||
test("consecutive frames turn smoothly", () => {
|
||||
const donut = bakeDonut();
|
||||
for (const view of Object.values(GRIDS)) {
|
||||
const { grid } = layoutGrid(view.width, view.height, GEIST);
|
||||
let previous = null;
|
||||
for (let t = 0; t < 8; t += 1 / 60) {
|
||||
const frame = renderFrame(donut, idleOrientation(t), grid, false);
|
||||
const drawn = frame.kinds.filter((kind) => kind !== KIND_EMPTY).length;
|
||||
if (previous) {
|
||||
let changed = 0;
|
||||
for (let i = 0; i < frame.kinds.length; i++) {
|
||||
if (
|
||||
(frame.kinds[i] === KIND_EMPTY) !==
|
||||
(previous[i] === KIND_EMPTY)
|
||||
) {
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
changed / drawn < 0.08,
|
||||
`${changed} of ${drawn} cells jump at t=${t.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
previous = frame.kinds.slice();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a flick fades back into the idle spin", () => {
|
||||
const run = (fps) => {
|
||||
let m = START_ORIENTATION;
|
||||
const momentum = { x: 6, y: -4, idle: 0 };
|
||||
for (let i = 0; i < 4 * fps; i++) m = spinStep(m, momentum, 1 / fps);
|
||||
return { m, momentum };
|
||||
};
|
||||
const fast = run(120);
|
||||
const slow = run(30);
|
||||
assert.equal(fast.momentum.x, 0);
|
||||
assert.equal(fast.momentum.y, 0);
|
||||
assert.ok(fast.momentum.idle > 0.99);
|
||||
for (let i = 0; i < 9; i++) assert.ok(Math.abs(fast.m[i] - slow.m[i]) < 1e-3);
|
||||
// Without the idle spin (reduced motion) the donut comes to rest.
|
||||
const momentum = { x: 3, y: 2, idle: 1 };
|
||||
let m = START_ORIENTATION;
|
||||
for (let i = 0; i < 300; i++) m = spinStep(m, momentum, 1 / 60, false);
|
||||
assert.deepEqual(momentum, { x: 0, y: 0, idle: 0 });
|
||||
assert.deepEqual(spinStep(m, momentum, 1 / 60, false), m);
|
||||
});
|
||||
|
||||
test("crumbs fall, fade and are gone when their life is over", () => {
|
||||
let crumbs = Array.from({ length: 10 }, (_, i) => ({
|
||||
x: 100,
|
||||
y: 50,
|
||||
vx: (i - 5) * 30,
|
||||
vy: -200,
|
||||
char: CRUMB_GLYPHS[i % CRUMB_GLYPHS.length],
|
||||
kind: KIND_DOUGH,
|
||||
age: 0,
|
||||
life: 1.1 + i * 0.08,
|
||||
}));
|
||||
let alpha = 1;
|
||||
for (let i = 0; i < 60 * 3 && crumbs.length > 0; i++) {
|
||||
crumbs = stepCrumbs(crumbs, 1 / 60, 200);
|
||||
for (const crumb of crumbs) assert.ok(crumb.y <= 200);
|
||||
if (crumbs.length > 0) {
|
||||
const last = crumbs[crumbs.length - 1];
|
||||
assert.ok(crumbAlpha(last) <= alpha + 1e-9);
|
||||
alpha = crumbAlpha(last);
|
||||
}
|
||||
}
|
||||
assert.equal(crumbs.length, 0);
|
||||
});
|
||||
|
||||
test("the lab measures every glyph it draws", () => {
|
||||
for (const char of DOUGH_RAMP + GLAZE_RAMP + CRUMB_RAMP + CRUMB_GLYPHS) {
|
||||
if (char !== " ") assert.ok(GLYPHS.includes(char), char);
|
||||
}
|
||||
for (const axis of [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
unit([1, 1, 0]),
|
||||
unit([-1, 1, 0]),
|
||||
[0, 0, 1],
|
||||
]) {
|
||||
assert.ok(GLYPHS.includes(sprinkleGlyph(axis)));
|
||||
}
|
||||
});
|
||||
+388
-35
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* The donut of the About easter egg: a signed distance field, a ray marcher,
|
||||
* bites carved out of the field, a mass estimate, and text shading. Pure math,
|
||||
* so it runs in node tests; the canvas lives in `donut-lab.tsx`.
|
||||
* bites carved out of the field, a mass estimate, text shading, the character
|
||||
* grid, and the motion (reveal, idle spin, flick, crumbs) as functions of
|
||||
* time. Pure math, so it runs in node tests; the canvas lives in
|
||||
* `donut-lab.tsx` and only paints what this module computes.
|
||||
*
|
||||
* Object space: the ring lies in the XZ plane around the Y axis, and +Y is the
|
||||
* glazed top. View space: +X right, +Y up, the camera on +Z looking at the
|
||||
* origin. A row-major rotation matrix turns object space into view space.
|
||||
* Rays go through canvas pixels with one scale on both axes, so the donut is
|
||||
* round whatever the shape of a character cell.
|
||||
*/
|
||||
|
||||
export type Vec3 = [number, number, number];
|
||||
@@ -26,7 +30,7 @@ export const MINOR_RADIUS = 0.45;
|
||||
export const GLAZE_THICKNESS = 0.06;
|
||||
/** Every point of the donut, sprinkles included, lies inside this sphere. */
|
||||
export const BOUNDS_RADIUS = 1.62;
|
||||
export const MAX_STEPS = 28;
|
||||
export const MAX_STEPS = 56;
|
||||
const SLAB_BOTTOM = -MINOR_RADIUS - 0.02;
|
||||
const SLAB_TOP = MINOR_RADIUS + GLAZE_THICKNESS + 0.08;
|
||||
const HIT_DISTANCE = 0.01;
|
||||
@@ -38,13 +42,24 @@ const BITE_SMOOTHING = 0.06;
|
||||
/** The donut is gone when this share of it is eaten, or after this many bites. */
|
||||
export const EATEN_SHARE = 0.85;
|
||||
export const MAX_BITES = 30;
|
||||
const CAMERA_DISTANCE = 5;
|
||||
const CAMERA_DISTANCE = 7;
|
||||
/** Half the side of the view, in object units, at the smaller canvas side. */
|
||||
const FRAME_RADIUS = 1.72;
|
||||
|
||||
export const DOUGH_RAMP = " .:-=+*#%@";
|
||||
export const GLAZE_RAMP = " .,~;oO08@";
|
||||
export const CRUMB_RAMP = " .,:;io0&@";
|
||||
export const SPRINKLE_GLYPHS = "-/|\\o";
|
||||
/** The glyphs of the crumbs that fly off a bite. */
|
||||
export const CRUMB_GLYPHS = ".,'`:;*";
|
||||
/** Every glyph the lab draws: the widest sets the width of a cell. */
|
||||
export const GLYPHS = [
|
||||
...new Set(
|
||||
DOUGH_RAMP + GLAZE_RAMP + CRUMB_RAMP + SPRINKLE_GLYPHS + CRUMB_GLYPHS,
|
||||
),
|
||||
]
|
||||
.filter((char) => char !== " ")
|
||||
.join("");
|
||||
export const SPRINKLE_COLORS = 5;
|
||||
|
||||
export type Material = "dough" | "glaze" | "crumb" | "sprinkle";
|
||||
@@ -505,6 +520,12 @@ export function rotationY(angle: number): Mat3 {
|
||||
return [c, 0, s, 0, 1, 0, -s, 0, c];
|
||||
}
|
||||
|
||||
export function rotationZ(angle: number): Mat3 {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
return [c, -s, 0, s, c, 0, 0, 0, 1];
|
||||
}
|
||||
|
||||
/** Keeps a matrix a rotation after many small turns. */
|
||||
export function orthonormalize(m: Mat3): Mat3 {
|
||||
const x: Vec3 = [m[0], m[1], m[2]];
|
||||
@@ -530,10 +551,34 @@ export function orthonormalize(m: Mat3): Mat3 {
|
||||
return [...x, ...y, ...z] as Mat3;
|
||||
}
|
||||
|
||||
/** Turns the donut about the screen's axes: a drag, or an arrow key. */
|
||||
/** A rotation by `angle` about a unit axis. */
|
||||
export function rotationAbout(axis: Vec3, angle: number): Mat3 {
|
||||
const [x, y, z] = axis;
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
const t = 1 - c;
|
||||
return [
|
||||
t * x * x + c,
|
||||
t * x * y - s * z,
|
||||
t * x * z + s * y,
|
||||
t * x * y + s * z,
|
||||
t * y * y + c,
|
||||
t * y * z - s * x,
|
||||
t * x * z - s * y,
|
||||
t * y * z + s * x,
|
||||
t * z * z + c,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the donut about the screen's axes, as one turn about their sum: a
|
||||
* drag, an arrow key, or a flick.
|
||||
*/
|
||||
export function turn(m: Mat3, aroundX: number, aroundY: number): Mat3 {
|
||||
const angle = Math.hypot(aroundX, aroundY);
|
||||
if (angle === 0) return m;
|
||||
return orthonormalize(
|
||||
multiply(multiply(rotationY(aroundY), rotationX(aroundX)), m),
|
||||
multiply(rotationAbout([aroundX / angle, aroundY / angle, 0], angle), m),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,12 +612,36 @@ export interface View {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Where the donut sits on a canvas: its center in CSS pixels, and its scale. */
|
||||
export interface Lens {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
/** The donut at rest: in the middle of the canvas, at full size. */
|
||||
export function centerLens(view: View): Lens {
|
||||
return { x: view.width / 2, y: view.height / 2, zoom: 1 };
|
||||
}
|
||||
|
||||
/** Object units per canvas pixel, the same on both axes. */
|
||||
function unitsPerPixel(view: View, lens: Lens) {
|
||||
return (
|
||||
(2 * FRAME_RADIUS) /
|
||||
(Math.max(1, Math.min(view.width, view.height)) * lens.zoom)
|
||||
);
|
||||
}
|
||||
|
||||
/** The ray through a canvas point, in view space. */
|
||||
export function viewRay(view: View, px: number, py: number) {
|
||||
const scale =
|
||||
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
|
||||
const x = (px - view.width / 2) * scale;
|
||||
const y = (view.height / 2 - py) * scale;
|
||||
export function viewRay(
|
||||
view: View,
|
||||
px: number,
|
||||
py: number,
|
||||
lens = centerLens(view),
|
||||
) {
|
||||
const scale = unitsPerPixel(view, lens);
|
||||
const x = (px - lens.x) * scale;
|
||||
const y = (lens.y - py) * scale;
|
||||
const length = Math.sqrt(x * x + y * y + CAMERA_DISTANCE * CAMERA_DISTANCE);
|
||||
return {
|
||||
origin: [0, 0, CAMERA_DISTANCE] as Vec3,
|
||||
@@ -581,14 +650,13 @@ export function viewRay(view: View, px: number, py: number) {
|
||||
}
|
||||
|
||||
/** Where an object-space point lands on the canvas. */
|
||||
export function project(view: View, m: Mat3, p: Vec3) {
|
||||
export function project(view: View, m: Mat3, p: Vec3, lens = centerLens(view)) {
|
||||
const [x, y, z] = toView(m, p);
|
||||
const scale =
|
||||
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
|
||||
const scale = unitsPerPixel(view, lens);
|
||||
const perspective = CAMERA_DISTANCE / (CAMERA_DISTANCE - z);
|
||||
return {
|
||||
x: view.width / 2 + (x * perspective) / scale,
|
||||
y: view.height / 2 - (y * perspective) / scale,
|
||||
x: lens.x + (x * perspective) / scale,
|
||||
y: lens.y - (y * perspective) / scale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -607,8 +675,9 @@ export function surfaceAt(
|
||||
view: View,
|
||||
px: number,
|
||||
py: number,
|
||||
lens = centerLens(view),
|
||||
): SurfacePoint | null {
|
||||
const ray = viewRay(view, px, py);
|
||||
const ray = viewRay(view, px, py, lens);
|
||||
const hit = castRay(donut, toObject(m, ray.origin), toObject(m, ray.dir));
|
||||
if (!hit) return null;
|
||||
const normal = normalAt(donut, hit.point);
|
||||
@@ -631,9 +700,10 @@ export function findBiteTarget(
|
||||
px: number,
|
||||
py: number,
|
||||
reach: number,
|
||||
lens = centerLens(view),
|
||||
step = 6,
|
||||
): (SurfacePoint & { x: number; y: number }) | null {
|
||||
const direct = surfaceAt(donut, m, view, px, py);
|
||||
const direct = surfaceAt(donut, m, view, px, py, lens);
|
||||
if (direct) return { ...direct, x: px, y: py };
|
||||
for (let radius = step; radius <= reach; radius += step) {
|
||||
const count = Math.max(8, Math.ceil((TAU * radius) / step));
|
||||
@@ -642,13 +712,38 @@ export function findBiteTarget(
|
||||
const x = px + Math.cos(angle) * radius;
|
||||
const y = py + Math.sin(angle) * radius;
|
||||
if (x < 0 || y < 0 || x > view.width || y > view.height) continue;
|
||||
const surface = surfaceAt(donut, m, view, x, y);
|
||||
const surface = surfaceAt(donut, m, view, x, y, lens);
|
||||
if (surface) return { ...surface, x, y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of a character cell, per pixel of font size, measured from the
|
||||
* font the canvas draws with.
|
||||
*/
|
||||
export interface CellMetrics {
|
||||
/** The advance of the widest glyph the lab draws. */
|
||||
advance: number;
|
||||
/** Ascent plus descent. */
|
||||
lineHeight: number;
|
||||
/** Every glyph has the same advance: a run of cells can be one string. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** Geist Mono: every glyph 0.6 wide, ascent 1.005, descent 0.295. */
|
||||
export const DEFAULT_CELL: CellMetrics = { advance: 0.6, lineHeight: 1.3 };
|
||||
/** Rows sit closer than lines of text: the ink of a glyph is shorter than its line. */
|
||||
const ROW_PITCH = 0.72;
|
||||
export const MIN_FONT_SIZE = 9;
|
||||
export const MAX_FONT_SIZE = 24;
|
||||
/** Columns across the donut, when the font size allows it. */
|
||||
export const COLUMNS_ACROSS = 48;
|
||||
/** The donut's width at rest, as a share of the canvas's smaller side. */
|
||||
export const DONUT_SPAN =
|
||||
(MAJOR_RADIUS + MINOR_RADIUS + GLAZE_THICKNESS) / FRAME_RADIUS;
|
||||
|
||||
/** The character grid laid over a canvas. */
|
||||
export interface Grid extends View {
|
||||
cols: number;
|
||||
@@ -665,21 +760,34 @@ export interface Frame {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a grid of about `targetCols` columns over a canvas. `advance` is the
|
||||
* width of one character per pixel of font size; rows are a little shorter
|
||||
* than the font, so the grid is denser than plain text.
|
||||
* Lays a grid of whole cells over a canvas, centered on it, so the donut is
|
||||
* about `across` columns wide. The cell is as wide as the font's widest glyph,
|
||||
* and each glyph is drawn at the center of its cell, so the font never moves
|
||||
* a glyph.
|
||||
*/
|
||||
export function layoutGrid(
|
||||
width: number,
|
||||
height: number,
|
||||
advance = 0.6,
|
||||
targetCols = 64,
|
||||
cell: CellMetrics = DEFAULT_CELL,
|
||||
across = COLUMNS_ACROSS,
|
||||
) {
|
||||
const cols = Math.max(24, Math.min(96, targetCols));
|
||||
const cellWidth = width / cols;
|
||||
const fontSize = cellWidth / advance;
|
||||
const cellHeight = fontSize * 0.92;
|
||||
const rows = Math.max(8, Math.floor(height / cellHeight));
|
||||
const advance =
|
||||
cell.advance >= 0.3 && cell.advance <= 1.5
|
||||
? cell.advance
|
||||
: DEFAULT_CELL.advance;
|
||||
const lineHeight =
|
||||
cell.lineHeight >= 0.8 && cell.lineHeight <= 2.5
|
||||
? cell.lineHeight
|
||||
: DEFAULT_CELL.lineHeight;
|
||||
const span = DONUT_SPAN * Math.max(1, Math.min(width, height));
|
||||
const fontSize = Math.min(
|
||||
MAX_FONT_SIZE,
|
||||
Math.max(MIN_FONT_SIZE, span / across / advance),
|
||||
);
|
||||
const cellWidth = fontSize * advance;
|
||||
const cellHeight = fontSize * lineHeight * ROW_PITCH;
|
||||
const cols = Math.max(1, Math.floor(width / cellWidth));
|
||||
const rows = Math.max(1, Math.floor(height / cellHeight));
|
||||
const grid: Grid = {
|
||||
width,
|
||||
height,
|
||||
@@ -687,7 +795,7 @@ export function layoutGrid(
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
offsetX: 0,
|
||||
offsetX: (width - cols * cellWidth) / 2,
|
||||
offsetY: (height - rows * cellHeight) / 2,
|
||||
};
|
||||
return { grid, fontSize };
|
||||
@@ -704,17 +812,23 @@ export function renderFrame(
|
||||
m: Mat3,
|
||||
grid: Grid,
|
||||
ink: boolean,
|
||||
frame: Frame = {
|
||||
chars: new Array(grid.cols * grid.rows).fill(" "),
|
||||
kinds: new Uint8Array(grid.cols * grid.rows),
|
||||
},
|
||||
reuse?: Frame,
|
||||
lens = centerLens(grid),
|
||||
): Frame {
|
||||
const size = grid.cols * grid.rows;
|
||||
const frame =
|
||||
reuse && reuse.kinds.length === size
|
||||
? reuse
|
||||
: {
|
||||
chars: new Array<string>(size).fill(" "),
|
||||
kinds: new Uint8Array(size),
|
||||
};
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
const py = grid.offsetY + (row + 0.5) * grid.cellHeight;
|
||||
for (let col = 0; col < grid.cols; col++) {
|
||||
const index = row * grid.cols + col;
|
||||
const px = grid.offsetX + (col + 0.5) * grid.cellWidth;
|
||||
const surface = surfaceAt(donut, m, grid, px, py);
|
||||
const surface = surfaceAt(donut, m, grid, px, py, lens);
|
||||
if (!surface) {
|
||||
frame.chars[index] = " ";
|
||||
frame.kinds[index] = KIND_EMPTY;
|
||||
@@ -746,3 +860,242 @@ export function renderFrame(
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `draw` for every drawn cell (only those of `kind`, when given) with
|
||||
* the center of its cell in canvas pixels. The canvas draws each glyph there,
|
||||
* centered, so glyphs of every kind and width stay on the grid.
|
||||
*/
|
||||
export function forEachGlyph(
|
||||
frame: Frame,
|
||||
grid: Grid,
|
||||
draw: (char: string, x: number, y: number, kind: number) => void,
|
||||
kind?: number,
|
||||
) {
|
||||
for (let i = 0; i < frame.kinds.length; i++) {
|
||||
const k = frame.kinds[i];
|
||||
if (k === KIND_EMPTY || (kind !== undefined && k !== kind)) continue;
|
||||
const col = i % grid.cols;
|
||||
const row = (i - col) / grid.cols;
|
||||
draw(
|
||||
frame.chars[i],
|
||||
grid.offsetX + (col + 0.5) * grid.cellWidth,
|
||||
grid.offsetY + (row + 0.5) * grid.cellHeight,
|
||||
k,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `draw` for every run of adjacent cells of one kind (only `kind`, when
|
||||
* given) in a row, with the run's text, the left edge of its first cell and
|
||||
* the middle of its row. With a monospace font whose advance is the cell
|
||||
* width, a run drawn from its left edge puts each glyph in its own cell, in
|
||||
* far fewer draw calls than one per glyph. Spaces are never drawn, so glyphs
|
||||
* of other kinds never shift a run.
|
||||
*/
|
||||
export function forEachRun(
|
||||
frame: Frame,
|
||||
grid: Grid,
|
||||
draw: (text: string, x: number, y: number, kind: number) => void,
|
||||
kind?: number,
|
||||
) {
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
const start = row * grid.cols;
|
||||
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
|
||||
let col = 0;
|
||||
while (col < grid.cols) {
|
||||
const k = frame.kinds[start + col];
|
||||
if (k === KIND_EMPTY || (kind !== undefined && k !== kind)) {
|
||||
col++;
|
||||
continue;
|
||||
}
|
||||
let end = col + 1;
|
||||
while (end < grid.cols && frame.kinds[start + end] === k) end++;
|
||||
draw(
|
||||
frame.chars.slice(start + col, start + end).join(""),
|
||||
grid.offsetX + col * grid.cellWidth,
|
||||
y,
|
||||
k,
|
||||
);
|
||||
col = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame as plain text, one line per row. */
|
||||
export function frameToText(frame: Frame, grid: Grid) {
|
||||
const lines: string[] = [];
|
||||
for (let row = 0; row < grid.rows; row++) {
|
||||
lines.push(
|
||||
frame.chars.slice(row * grid.cols, (row + 1) * grid.cols).join(""),
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The idle spin, in radians per second: the classic two-axis turn. The donut
|
||||
* tumbles about its own X axis while the picture rolls about the line of
|
||||
* sight. One turn acts in object space and the other in view space, so they
|
||||
* commute: the spin after t seconds is the same at any frame rate.
|
||||
*/
|
||||
export const IDLE_TUMBLE = 0.9;
|
||||
export const IDLE_ROLL = 0.42;
|
||||
|
||||
/** Turns the donut by `seconds` of the idle spin at full speed. */
|
||||
export function idleStep(m: Mat3, seconds: number): Mat3 {
|
||||
if (seconds === 0) return m;
|
||||
return orthonormalize(
|
||||
multiply(
|
||||
multiply(rotationZ(IDLE_ROLL * seconds), m),
|
||||
rotationX(IDLE_TUMBLE * seconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The donut `seconds` into the idle spin from `start`. */
|
||||
export function idleOrientation(seconds: number, start = START_ORIENTATION) {
|
||||
return idleStep(start, seconds);
|
||||
}
|
||||
|
||||
/** How fast a flick fades into the idle spin, per second. */
|
||||
export const SETTLE_RATE = 2.6;
|
||||
/** The fastest flick, in radians per second. */
|
||||
export const MAX_FLICK = 9;
|
||||
|
||||
/**
|
||||
* A flick's speed about the screen's X and Y axes, in radians per second, and
|
||||
* the share of the idle spin that runs (0 while a hand holds the donut).
|
||||
*/
|
||||
export interface Momentum {
|
||||
x: number;
|
||||
y: number;
|
||||
idle: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the donut `dt` seconds on. The flick fades and the idle spin comes
|
||||
* back at the same rate, so a flick hands over to the idle spin with no jump.
|
||||
* Each is integrated exactly, and the flick sits between two halves of the
|
||||
* idle step, so the turn hardly depends on the frame rate.
|
||||
*/
|
||||
export function spinStep(
|
||||
m: Mat3,
|
||||
momentum: Momentum,
|
||||
dt: number,
|
||||
idle = true,
|
||||
): Mat3 {
|
||||
if (!(dt > 0)) return m;
|
||||
const fade = Math.exp(-SETTLE_RATE * dt);
|
||||
const halfFade = Math.exp((-SETTLE_RATE * dt) / 2);
|
||||
// Integrals of e^(-k s) over the step and over half of it.
|
||||
const share = (1 - fade) / SETTLE_RATE;
|
||||
const halfShare = (1 - halfFade) / SETTLE_RATE;
|
||||
// The idle share is 1 - (1 - w0) e^(-k s); its integral over each half:
|
||||
const lag = idle ? 1 - momentum.idle : 1;
|
||||
const firstHalf = idle ? dt / 2 - lag * halfShare : 0;
|
||||
const secondHalf = idle ? dt / 2 - lag * halfFade * halfShare : 0;
|
||||
let next = idleStep(m, firstHalf);
|
||||
if (momentum.x !== 0 || momentum.y !== 0) {
|
||||
next = turn(next, momentum.x * share, momentum.y * share);
|
||||
momentum.x *= fade;
|
||||
momentum.y *= fade;
|
||||
if (Math.abs(momentum.x) + Math.abs(momentum.y) < 1e-3) {
|
||||
momentum.x = 0;
|
||||
momentum.y = 0;
|
||||
}
|
||||
}
|
||||
momentum.idle = idle ? 1 - lag * fade : 0;
|
||||
return idleStep(next, secondHalf);
|
||||
}
|
||||
|
||||
/** How long the donut takes to come out of the logo. */
|
||||
export const REVEAL_MS = 560;
|
||||
/** The extra tumble of the reveal: the donut flips over once as it lands. */
|
||||
const REVEAL_TWIST = Math.PI;
|
||||
/** The smallest scale a reveal starts at, so its first frame is a donut already. */
|
||||
const REVEAL_MIN_ZOOM = 0.22;
|
||||
|
||||
/** Where a reveal starts: the logo's center and size, in canvas pixels. */
|
||||
export interface RevealOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Pose {
|
||||
orientation: Mat3;
|
||||
lens: Lens;
|
||||
}
|
||||
|
||||
/**
|
||||
* The donut `elapsedMs` into its reveal: it comes out of the logo at the
|
||||
* logo's size, moves to the middle as it grows, and flips over once, fast at
|
||||
* first. From REVEAL_MS on, and with no origin, it is exactly the pose at rest.
|
||||
*/
|
||||
export function revealPose(
|
||||
m: Mat3,
|
||||
view: View,
|
||||
origin: RevealOrigin | null,
|
||||
elapsedMs: number,
|
||||
): Pose {
|
||||
const rest = centerLens(view);
|
||||
if (!origin || !(elapsedMs < REVEAL_MS))
|
||||
return { orientation: m, lens: rest };
|
||||
const p = Math.max(0, elapsedMs) / REVEAL_MS;
|
||||
const eased = 1 - (1 - p) ** 3;
|
||||
const span = DONUT_SPAN * Math.max(1, Math.min(view.width, view.height));
|
||||
const startZoom = Math.min(1, Math.max(REVEAL_MIN_ZOOM, origin.size / span));
|
||||
return {
|
||||
orientation: orthonormalize(
|
||||
multiply(m, rotationX(-REVEAL_TWIST * (1 - eased))),
|
||||
),
|
||||
lens: {
|
||||
x: origin.x + (rest.x - origin.x) * eased,
|
||||
y: origin.y + (rest.y - origin.y) * eased,
|
||||
zoom: startZoom + (1 - startZoom) * eased,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A crumb that flies off a bite, in canvas pixels. */
|
||||
export interface Crumb {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
char: string;
|
||||
kind: number;
|
||||
/** Seconds since the bite, and seconds the crumb lasts. */
|
||||
age: number;
|
||||
life: number;
|
||||
}
|
||||
|
||||
/** Pixels per second, per second. */
|
||||
export const CRUMB_GRAVITY = 1100;
|
||||
/** Seconds a crumb takes to fade out at the end of its life. */
|
||||
const CRUMB_FADE = 0.4;
|
||||
|
||||
/**
|
||||
* Moves the crumbs `dt` seconds on, bouncing on `floor`, and returns the ones
|
||||
* whose life is not over.
|
||||
*/
|
||||
export function stepCrumbs(crumbs: Crumb[], dt: number, floor: number) {
|
||||
return crumbs.filter((crumb) => {
|
||||
crumb.age += dt;
|
||||
crumb.vy += CRUMB_GRAVITY * dt;
|
||||
crumb.x += crumb.vx * dt;
|
||||
crumb.y += crumb.vy * dt;
|
||||
if (crumb.y > floor) {
|
||||
crumb.y = floor;
|
||||
crumb.vy = Math.abs(crumb.vy) < 60 ? 0 : -crumb.vy * 0.3;
|
||||
crumb.vx *= 0.55;
|
||||
}
|
||||
return crumb.age < crumb.life;
|
||||
});
|
||||
}
|
||||
|
||||
export function crumbAlpha(crumb: Crumb) {
|
||||
return Math.min(1, Math.max(0, (crumb.life - crumb.age) / CRUMB_FADE));
|
||||
}
|
||||
Reference in new issue
Block a user