feat: floodlit roadside billboard carries the selected-builds case studies

This commit is contained in:
2026-10-11 07:09:31 +03:00
parent 19f3ce79e6
commit d71b0ecfd4
31 changed files with 1216 additions and 6 deletions

View File

@@ -173,6 +173,13 @@ describe('MILE MARKERS on the server', () => {
});
});
describe('DRIVE-IN on the server', () => {
it('ships no billboard state: the case studies render unchanged', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-billboard');
});
});
describe('HIGH BEAM on the server', () => {
it('ships no beam state: the name renders fully lit and its text is unchanged', async () => {
const html = await render('/', validInputs);

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
activeStop,
billboardProgress,
billboardStops,
floodTargets,
publishBillboard,
readBillboard,
RIGHT_FLOOD_T,
screenKind,
} from '@/journey/billboard-progress';
const IDS = ['private-medication-tracker', 'footy-tracker', 'opinion-dynamics-simulation'];
// Document tops measured at 1440: chapter heading 7044, headers 7171/8647/9809, next 11000.
const STOPS = billboardStops(7044, 11000, [7171, 8647, 9809]);
const sweep = (from: number, to: number, steps = 400): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
describe('billboard stops', () => {
it('maps header tops to chapter-local fractions, in order and clamped', () => {
expect(STOPS).toHaveLength(3);
expect(STOPS[0]).toBe(0);
expect(STOPS[1]).toBeCloseTo((8647 - 7044) / (11000 - 7044));
expect([...STOPS].sort((a, b) => a - b)).toEqual(STOPS);
expect(billboardStops(0, 100, [-10, 50, 150])).toEqual([0, 0.5, 1]);
});
it('is empty when the chapter has no span (unmeasured layout)', () => {
expect(billboardStops(100, 100, [100])).toEqual([]);
expect(billboardProgress(0.5, [], IDS).activeProjectId).toBeNull();
});
});
describe('billboard progress', () => {
it('activates the projects in reading order, one at a time', () => {
const order: (string | null)[] = [];
for (const p of sweep(0, 1)) {
const id = billboardProgress(p, STOPS, IDS, 1).activeProjectId;
if (order.at(-1) !== id) order.push(id);
}
// The first case study holds the board from the chapter's first scroll (stop 0).
expect(order).toEqual([...IDS]);
expect(billboardProgress(null, STOPS, IDS, 1).activeProjectId).toBeNull();
});
it('holds nothing before the first header or outside the chapter', () => {
expect(billboardProgress(STOPS[0]! - 0.001, STOPS, IDS).activeProjectId).toBeNull();
expect(billboardProgress(null, STOPS, IDS)).toMatchObject({ activeProjectId: null, index: -1, t: 0 });
expect(billboardProgress(STOPS[0]!, STOPS, IDS)).toMatchObject({ activeProjectId: IDS[0], index: 0, t: 0 });
});
it('ramps t from 0 at each header to 1 at the next, monotonically', () => {
IDS.forEach((id, index) => {
const from = STOPS[index]!;
const to = STOPS[index + 1] ?? 1;
let previous = -1;
for (const p of sweep(from, to - 1e-9, 50)) {
const progress = billboardProgress(p, STOPS, IDS);
expect(progress.activeProjectId).toBe(id);
expect(progress.t).toBeGreaterThanOrEqual(previous);
expect(progress.t).toBeGreaterThanOrEqual(0);
expect(progress.t).toBeLessThanOrEqual(1);
previous = progress.t;
}
expect(billboardProgress(from + (to - from) / 2, STOPS, IDS).t).toBeCloseTo(0.5);
});
});
it('switches the left flood on at t 0 and the right at t 0.08', () => {
const at = (t: number) => floodTargets(billboardProgress(STOPS[1]! + t * (STOPS[2]! - STOPS[1]!), STOPS, IDS));
expect(floodTargets(billboardProgress(null, STOPS, IDS))).toEqual([0, 0]);
expect(floodTargets(billboardProgress(0, STOPS, IDS))).toEqual([1, 0]);
expect(at(0)).toEqual([1, 0]);
expect(at(RIGHT_FLOOD_T - 0.01)).toEqual([1, 0]);
expect(at(RIGHT_FLOOD_T + 0.001)).toEqual([1, 1]);
});
it('runs backwards on reverse scroll: the same state at every p, direction carried', () => {
const forward = sweep(0, 1).map((p) => billboardProgress(p, STOPS, IDS, 1));
const backward = sweep(1, 0).map((p) => billboardProgress(p, STOPS, IDS, -1)).reverse();
forward.forEach((state, i) => {
expect(backward[i]!.activeProjectId).toBe(state.activeProjectId);
expect(backward[i]!.t).toBeCloseTo(state.t);
expect(floodTargets(backward[i]!)).toEqual(floodTargets(state));
expect(backward[i]!.direction).toBe(-1);
});
const order: (string | null)[] = [];
for (const state of [...backward].reverse()) if (order.at(-1) !== state.activeProjectId) order.push(state.activeProjectId);
expect(order).toEqual([...IDS].reverse());
});
});
describe('DOM and scene agreement', () => {
it('lights the same case study in the page (activeStop) as on the board', () => {
for (const p of [null, ...sweep(0, 1)]) {
const index = activeStop(p, STOPS, IDS.length);
expect(IDS[index] ?? null).toBe(billboardProgress(p, STOPS, IDS).activeProjectId);
}
});
});
describe('billboard screens', () => {
afterEach(() => publishBillboard([], []));
it('shows a triptych, a chart pair, or the typographic plate when there is no media', () => {
const shot = (kind: string) => ({ id: kind, url: `/assets/${kind}.webp`, kind });
expect(screenKind({ shots: [shot('image'), shot('image'), shot('image')] })).toBe('triptych');
expect(screenKind({ shots: [shot('chart'), shot('chart')] })).toBe('pair');
expect(screenKind({ shots: [] })).toBe('plate');
});
it('shares the published projects and stops with the scene', () => {
const projects = [{ id: 'footy-tracker', title: 'Footy tracker', plate: '05.2 / PUBLIC / ACTIVE', shots: [] }];
publishBillboard(projects, [0.2]);
expect(readBillboard()).toEqual({ projects, stops: [0.2] });
});
});

View File

@@ -0,0 +1,215 @@
import { afterEach, describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { publishBillboard, billboardStops, type BillboardProject } from '@/journey/billboard-progress';
import {
BILLBOARD_TIMING,
BILLBOARD_Z_FROM,
BILLBOARD_Z_TO,
billboardNear,
billboardZ,
composeScreen,
createBillboard,
SCREEN_ASPECT,
STANDING,
textureSize,
zoomFit,
type BillboardFrame,
type BillboardIO,
} from '@/scene/world/billboard';
import type { SectionId } from '@/content/types';
const PROJECTS: BillboardProject[] = [
{
id: 'private-medication-tracker',
title: 'Medication tracker',
plate: '05.1 / PRIVATE / ACTIVE',
shots: ['logs', 'data', 'notify'].map((id) => ({ id, url: `/assets/${id}.webp`, kind: 'image' })),
},
{ id: 'footy-tracker', title: 'Footy tracker', plate: '05.2 / PUBLIC / ACTIVE', shots: [] },
{
id: 'opinion-dynamics-simulation',
title: 'Opinion dynamics',
plate: '05.3 / PUBLIC / SHIPPED',
shots: ['polar', 'final'].map((id) => ({ id, url: `/assets/${id}.webp`, kind: 'chart' })),
},
];
const STOPS = billboardStops(0, 100, [5, 40, 70]);
/** A canvas stand-in that records what was drawn (happy-dom has no 2D context). */
function fakeIO(fail: (url: string) => boolean = () => false) {
const drawn: string[] = [];
const io: BillboardIO = {
loadImage: (url) => (fail(url) ? Promise.reject(new Error(`404 ${url}`)) : Promise.resolve({ width: 720, height: 1280 } as never)),
canvas(width, height) {
const canvas = { width, height } as HTMLCanvasElement;
const context = {
fillRect: () => undefined,
fillText: (text: string) => drawn.push(`text:${text}`),
drawImage: () => drawn.push('image'),
measureText: (text: string) => ({ width: text.length * 10 }),
} as unknown as CanvasRenderingContext2D;
return { canvas, context };
},
};
return { io, drawn };
}
const flush = async (): Promise<void> => {
for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
};
const frame = (section: SectionId, p: number | null, extra: Partial<BillboardFrame> = {}): BillboardFrame => ({
section,
p,
direction: 1,
near: true,
pulse: 0,
energy: 0,
focus: undefined,
deltaSeconds: 1 / 60,
...extra,
});
/** Runs `seconds` of frames and returns each frame's state. */
const run = (board: ReturnType<typeof createBillboard>, seconds: number, f: BillboardFrame) =>
Array.from({ length: Math.round(seconds * 60) }, () => {
board.update(f);
return board.inspect();
});
describe('billboard placement', () => {
it('drifts from BILLBOARD_Z_FROM to BILLBOARD_Z_TO across the chapter, monotonically', () => {
let previous = Number.NEGATIVE_INFINITY;
for (let i = 0; i <= 100; i += 1) {
const z = billboardZ(i / 100);
expect(z).toBeGreaterThanOrEqual(previous);
previous = z;
}
expect(billboardZ(0)).toBe(BILLBOARD_Z_FROM);
expect(billboardZ(1)).toBe(BILLBOARD_Z_TO);
expect(billboardZ(Number.NaN)).toBe(BILLBOARD_Z_FROM);
});
it('starts building textures within one viewport of the chapter, not before', () => {
const at = (activeSectionId: SectionId, nextHeadingOffsetVh: number | null) =>
billboardNear({ activeSectionId, sectionActivation: { nextHeadingOffsetVh } as never });
expect(at('start', 0.5)).toBe(false);
expect(at('infrastructure', 3)).toBe(false);
expect(at('infrastructure', 1.2)).toBe(true);
expect(at('selected-builds', null)).toBe(true);
expect(textureSize('high')).toBe(1024);
expect(textureSize('medium')).toBe(512);
expect(textureSize('low')).toBe(512);
});
});
describe('billboard screens', () => {
it('lays the triptych and chart pair out at their own aspect on a screen-shaped canvas', async () => {
const { io, drawn } = fakeIO();
const triptych = await composeScreen(PROJECTS[0]!, 1024, io);
expect(triptych?.kind).toBe('triptych');
expect(triptych?.canvas.width).toBe(1024);
expect(triptych?.canvas.height).toBe(Math.round(1024 / SCREEN_ASPECT));
expect(drawn.filter((entry) => entry === 'image')).toHaveLength(3);
for (const rect of triptych!.rects.values()) {
expect(rect.u).toBeGreaterThanOrEqual(0);
expect(rect.u + rect.w).toBeLessThanOrEqual(1);
// Portrait shots keep 720:1280 in screen space.
expect((rect.w * SCREEN_ASPECT) / rect.h).toBeCloseTo(720 / 1280, 2);
const [sx, sy] = zoomFit(rect);
expect(sx).toBeGreaterThanOrEqual(1);
expect(sy).toBe(1);
}
expect((await composeScreen(PROJECTS[2]!, 512, io))?.kind).toBe('pair');
});
it('draws the typographic plate for a project with no media: title and route plate, no images', async () => {
const { io, drawn } = fakeIO();
const plate = await composeScreen(PROJECTS[1]!, 1024, io);
expect(plate?.kind).toBe('plate');
expect(drawn).toEqual(['text:05.2 / PUBLIC / ACTIVE', 'text:Footy tracker']);
});
it('falls back to the plate when any shot fails to load, and never rejects', async () => {
const { io, drawn } = fakeIO((url) => url.includes('data'));
const screen = await composeScreen(PROJECTS[0]!, 512, io);
expect(screen?.kind).toBe('plate');
expect(drawn).toContain('text:Medication tracker');
const noCanvas: BillboardIO = { ...io, canvas: () => null };
await expect(composeScreen(PROJECTS[0]!, 512, noCanvas)).resolves.toBeNull();
const throwing: BillboardIO = { ...io, canvas: () => { throw new Error('no 2d'); } };
await expect(composeScreen(PROJECTS[0]!, 512, throwing)).resolves.toBeNull();
});
});
describe('billboard world', () => {
afterEach(() => publishBillboard([], []));
it('rises monotonically on the Selected builds envelope and sinks after', () => {
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
const up = run(board, 10, frame('selected-builds', 0));
up.slice(1).forEach((state, i) => expect(state.rise).toBeGreaterThanOrEqual(up[i]!.rise));
expect(up.at(-1)!.rise).toBe(1);
const down = run(board, 10, frame('start', null));
down.slice(1).forEach((state, i) => expect(state.rise).toBeLessThanOrEqual(down[i]!.rise));
expect(down.at(-1)!.rise).toBe(0);
expect(board.mesh.visible).toBe(false);
});
it('keeps the floods off while rising, then lights each project in order and reverses', async () => {
publishBillboard(PROJECTS, STOPS);
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
const rising = run(board, 0.5, frame('selected-builds', 0.06));
expect(rising.every((state) => state.floods[0] === 0 && state.floods[1] === 0)).toBe(true);
await flush();
expect(board.inspect().textures).toBe(3);
run(board, 5, frame('selected-builds', 0.02));
// t = 0.05 < 0.08: only the left flood.
expect(board.inspect()).toMatchObject({ shown: 'private-medication-tracker', floods: [1, 0] });
expect(board.inspect().lit).toBe(1);
expect(board.inspect().nudge).toBe(1);
run(board, 0.5, frame('selected-builds', 0.2));
expect(board.inspect().floods).toEqual([1, 1]);
// The next project: grey first, then the swap, then lit over 0.6 s.
const swap = run(board, 1, frame('selected-builds', 0.5));
const swapAt = swap.findIndex((state) => state.shown === 'footy-tracker');
expect(swapAt).toBeGreaterThan(0);
expect(swap[swapAt]!.lit).toBe(0);
const litAt = swap.findIndex((state) => state.shown === 'footy-tracker' && state.lit === 1);
expect((litAt - swapAt) / 60).toBeCloseTo(BILLBOARD_TIMING.lit, 1);
// Reverse: the first case study keeps the board back to the chapter's first scroll...
run(board, 2, frame('selected-builds', 0.01, { direction: -1 }));
expect(board.inspect()).toMatchObject({ shown: 'private-medication-tracker', floods: [1, 0], lit: 1 });
// ...and it stays lit on the approach band; leaving the chapter drops it and everything goes dark.
run(board, 2, frame('infrastructure', null, { direction: -1 }));
expect(board.inspect()).toMatchObject({ floods: [1, 0], lit: 1 });
run(board, 4, frame('infrastructure', null, { direction: -1, near: false }));
expect(board.inspect()).toMatchObject({ floods: [0, 0], lit: 0, nudge: 0 });
expect(board.inspect().rise).toBeLessThan(STANDING);
});
it('flickers at most twice a second on bass hits, and only in the full tier', async () => {
publishBillboard(PROJECTS, STOPS);
const full = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
const lite = createBillboard(new SceneResourceBag(), { mode: 'lite', textureSize: 512, io: fakeIO().io });
for (const board of [full, lite]) run(board, 5, frame('selected-builds', 0.2));
await flush();
// A bass hit every other frame for 3 s: 90 rising edges.
for (let i = 0; i < 180; i += 1) {
const f = frame('selected-builds', 0.2, { pulse: i % 2 === 0 ? 0.9 : 0.1, energy: 0.8, focus: 'logs' });
full.update(f);
lite.update(f);
}
expect(full.inspect().flickers).toBeGreaterThan(0);
expect(full.inspect().flickers).toBeLessThanOrEqual(Math.ceil(3 / BILLBOARD_TIMING.flickerGap));
expect(full.inspect().shimmers).toBe(1);
expect(full.inspect().zoom).toBe(1);
// Lite: one steady flood, no pulse, flicker, shimmer or hover zoom.
expect(lite.inspect()).toMatchObject({ floods: [1, 0], flickers: 0, shimmers: 0, zoom: 0 });
});
it('never throws into the render loop', () => {
const board = createBillboard(new SceneResourceBag(), { mode: 'full', textureSize: 512, io: fakeIO().io });
expect(() => board.update(frame('selected-builds', Number.NaN, { deltaSeconds: Number.NaN }))).not.toThrow();
});
});