feat: floodlit roadside billboard carries the selected-builds case studies
This commit is contained in:
@@ -11,6 +11,25 @@ export interface SceneInstrumentationSnapshot {
|
||||
clearColors: string[];
|
||||
unhandledRejections: string[];
|
||||
signFrames: SignFrameInstrumentation[];
|
||||
/** DRIVE-IN: one entry per billboard state change (portfolio-billboard-frame, test builds only). */
|
||||
billboardFrames: BillboardFrameInstrumentation[];
|
||||
}
|
||||
|
||||
export interface BillboardFrameInstrumentation {
|
||||
now: number;
|
||||
activeSectionId: string;
|
||||
direction: number;
|
||||
rise: number;
|
||||
z: number;
|
||||
progress: { activeProjectId: string | null; index: number; t: number; direction: number };
|
||||
shown: string | null;
|
||||
lit: number;
|
||||
floods: [number, number];
|
||||
zoom: number;
|
||||
nudge: number;
|
||||
flickers: number;
|
||||
shimmers: number;
|
||||
textures: number;
|
||||
}
|
||||
|
||||
export interface SignFrameInstrumentation {
|
||||
@@ -60,6 +79,7 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
|
||||
clearColors: [],
|
||||
unhandledRejections: [],
|
||||
signFrames: [],
|
||||
billboardFrames: [],
|
||||
animationFrameIds: new Set<number>(),
|
||||
audioContexts: [],
|
||||
};
|
||||
@@ -166,6 +186,11 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
|
||||
if (!detail || state.signFrames.length >= 600) return;
|
||||
state.signFrames.push(structuredClone(detail));
|
||||
});
|
||||
window.addEventListener('portfolio-billboard-frame', (event) => {
|
||||
const detail = (event as CustomEvent<BillboardFrameInstrumentation>).detail;
|
||||
if (!detail || state.billboardFrames.length >= 600) return;
|
||||
state.billboardFrames.push(structuredClone(detail));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +207,7 @@ export async function readSceneInstrumentation(page: Page): Promise<SceneInstrum
|
||||
clearColors: [...state.clearColors],
|
||||
unhandledRejections: [...state.unhandledRejections],
|
||||
signFrames: state.signFrames.map((frame) => structuredClone(frame)),
|
||||
billboardFrames: state.billboardFrames.map((frame) => structuredClone(frame)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
118
tests/unit/billboard-progress.test.ts
Normal file
118
tests/unit/billboard-progress.test.ts
Normal 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] });
|
||||
});
|
||||
});
|
||||
215
tests/unit/billboard.test.ts
Normal file
215
tests/unit/billboard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user