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

@@ -39,3 +39,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Known weaknesses:** the free side is narrow because of the camera frame shift, so the big pass is brief; plates read mostly white under tonemapping; no lit number glyphs on the plates.
- Screenshots: docs/overnight/round4/.
## Round 5: Drive-In (Selected builds billboard)
- **What:** a floodlit roadside billboard (22 x 15 m, right shoulder) carries the case-study media in the world: medication tracker triptych, opinion-dynamics chart pair, footy-tracker typographic plate. Floods relight per project as scroll passes each case study (reverse runs backwards), light spill on the shoulder, hover on a gallery image zooms that shot onto the board, floods breathe with the bass pulse. Reduced motion/static: CSS flood gradient on the active gallery.
- **Why:** Selected builds hold the only real proof and had no world beat; DOM evidence and scene now share one object.
- **Build:** scene/world/billboard.ts, journey/billboard-progress.ts + billboard-feed.ts, useBillboard.ts, billboard.css. 786 tests, initial JS 116.5 KB gz (**3.5 KB left**), scene chunk ~210 KB.
- **Verdict: KEEP with fixes** (Fable, hardware GL): the delivered board was an 80 px speck, so it was rebuilt at 2.4x, moved to x 24, lit at chapter entry, floods widened, charts dimmed so bloom does not blow them out; narrow-viewport placement added.
- **Known weaknesses:** the 92%-wide panel hides the board while reading (visible at chapter entry, in gaps between case studies and at exit); phone screenshots read dim; ~140 px on phones; test-only telemetry.
- Screenshots: docs/overnight/round5/.

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -18,6 +18,7 @@ import './styles/maintenance.css';
import './styles/print.css';
import './styles/headlight.css';
import './styles/route-log.css';
import './styles/billboard.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>

View File

@@ -23,6 +23,7 @@ import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { useRouteLog } from '@/composables/useRouteLog';
import { useBillboard } from '@/composables/useBillboard';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
@@ -89,6 +90,8 @@ const journey = journeyBinding.snapshot;
const maintenanceSubline = useMaintenanceWindow(journey);
/* MILE MARKERS (Experience, Profile): the ROUTE LOG odometer on the same sub-line. */
const routeLogSubline = useRouteLog(journey, () => props.content);
/* DRIVE-IN (Selected builds): the floodlit billboard follows the case studies. */
useBillboard(journey, () => props.content.projects.selected, props.manifest, exitFor('selected-builds'));
useCardEntrances();
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
const resume = computed(() => {

View File

@@ -0,0 +1,38 @@
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
import type { PublicAssetManifest, PublicProject } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types';
import { assetUrl } from '@/content/asset-url';
import { routePlate } from '@/lib/route-plate';
import { sectionLocalProgress } from '@/journey/route-log';
import { activeStop, billboardStops, publishBillboard, readBillboard } from '@/journey/billboard-feed';
/** DRIVE-IN, DOM side: html[data-billboard-active] and (fine pointer) html[data-billboard-focus]. After mount only. */
export function useBillboard(journey: Readonly<Ref<JourneySnapshot>>, list: () => readonly PublicProject[], manifest: PublicAssetManifest, exit: string): void {
const stops = shallowRef<number[]>([]);
const html = (): DOMStringMap => document.documentElement.dataset;
const top = (el: Element | null | undefined): number => (el?.getBoundingClientRect().top ?? 0) + scrollY;
const cases = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>('#selected-builds [data-project-id]')];
const measure = (): void => {
stops.value = billboardStops(top(document.getElementById('selected-builds-heading')), top(document.getElementById('additional-work-heading')), cases().map((el) => top(el.firstElementChild)));
publishBillboard(list().map((p, i) => ({ id: p.id, title: p.title, plate: routePlate(exit, i, p),
shots: (p.media ?? []).flatMap((m) => (m.kind === 'video' ? [] : [{ id: m.assetId, url: assetUrl(manifest, m.assetId), kind: m.kind }])) })), stops.value);
};
const active = computed(() => list()[activeStop(sectionLocalProgress(journey.value, 'selected-builds'), stops.value, list().length)]?.id ?? null);
watch(() => journey.value.sectionActivation.layoutRevision, measure);
watch(active, (id) => {
if (id === null) delete html().billboardActive; else html().billboardActive = id;
for (const el of cases()) el.toggleAttribute('data-billboard-lit', el.dataset.projectId === id);
});
let idle = 0;
const hover = (event: Event): void => {
const src = (event.target as Element).closest?.('.project-media-gallery img')?.getAttribute('src');
const shot = readBillboard().projects.flatMap((p) => p.shots).find((s) => s.url === src);
clearTimeout(idle);
if (shot !== undefined && event.type === 'pointerover') html().billboardFocus = shot.id;
else if (shot !== undefined) idle = window.setTimeout(() => delete html().billboardFocus, 1200);
};
const listen = (on: boolean): void => ['pointerover', 'pointerout'].forEach((type) =>
document.getElementById('selected-builds')?.[on ? 'addEventListener' : 'removeEventListener'](type, hover));
onMounted(() => { measure(); if (window.matchMedia?.('(hover: hover) and (pointer: fine)').matches) listen(true); });
onUnmounted(() => { listen(false); clearTimeout(idle); delete html().billboardActive; delete html().billboardFocus; });
}

View File

@@ -0,0 +1,47 @@
/**
* DRIVE-IN, the part the DOM needs (initial chunk): the case-study stops and
* the slot the scene reads. The beats themselves (t, floods, screen kind)
* live in billboard-progress.ts, which only the lazy scene chunk loads.
*/
export interface BillboardShot {
/** Manifest asset id (html[data-billboard-focus] names it). */
id: string;
url: string;
kind: string;
}
export interface BillboardProject {
id: string;
title: string;
/** The route plate, e.g. "05.2 / PUBLIC / ACTIVE" (src/lib/route-plate.ts). */
plate: string;
shots: readonly BillboardShot[];
}
/**
* Chapter-local progress at which each case-study header crosses the
* activation line, from document tops (the chapter heading, each header,
* the next chapter's heading). The first case study takes the board as the
* chapter opens (stop 0), so the board is lit in the band above the chapter
* heading. Scroll-invariant: measure once per layout.
*/
export function billboardStops(chapterTop: number, nextTop: number, headerTops: readonly number[]): number[] {
const span = nextTop - chapterTop;
return span > 0 ? headerTops.map((top, i) => (i === 0 ? 0 : Math.min(1, Math.max(0, (top - chapterTop) / span)))) : [];
}
/** Index of the case study holding the board at chapter progress p: the last stop passed, else -1. */
export const activeStop = (p: number | null, stops: readonly number[], count: number): number =>
p === null ? -1 : Math.min(count, stops.filter((stop) => p >= stop).length) - 1;
/**
* The DOM publishes the projects and their stops here once per layout; the
* scene reads them inside its own frame. Nothing here schedules work.
*/
let feed: { projects: readonly BillboardProject[]; stops: readonly number[] } = { projects: [], stops: [] };
export function publishBillboard(projects: readonly BillboardProject[], stops: readonly number[]): void {
feed = { projects, stops };
}
export const readBillboard = (): typeof feed => feed;

View File

@@ -0,0 +1,56 @@
/**
* DRIVE-IN: Selected builds carries the only real proof (three phone shots,
* two charts), so a floodlit billboard on the shoulder shows it in the world.
* Each case study takes the board when its header crosses the activation
* line, the same line that lights the chapters. Scroll-driven, so scrolling
* back runs every beat backwards. Pure; loaded by the lazy scene chunk only (the
* DOM side uses billboard-feed.ts, which this re-exports).
*/
import { activeStop, type BillboardProject } from './billboard-feed';
export {
activeStop,
billboardStops,
publishBillboard,
readBillboard,
type BillboardProject,
type BillboardShot,
} from './billboard-feed';
export interface BillboardProgress {
activeProjectId: string | null;
index: number;
/** Project-local progress: 0 with its header on the line, 1 at the next header. */
t: number;
direction: -1 | 0 | 1;
}
/** The right flood follows the left once the project is this far along. */
export const RIGHT_FLOOD_T = 0.08;
/** Which project holds the board at chapter progress p (null before the first header). */
export function billboardProgress(
p: number | null,
stops: readonly number[],
ids: readonly string[],
direction: -1 | 0 | 1 = 0,
): BillboardProgress {
const index = activeStop(p, stops, ids.length);
if (index < 0 || p === null) return { activeProjectId: null, index: -1, t: 0, direction };
const from = stops[index]!;
const to = stops[index + 1] ?? 1;
return {
activeProjectId: ids[index]!,
index,
t: to > from ? Math.min(1, (p - from) / (to - from)) : 1,
direction,
};
}
/** Left flood on at t 0, right at RIGHT_FLOOD_T; both off without a project. */
export const floodTargets = (progress: BillboardProgress): [number, number] =>
progress.activeProjectId === null ? [0, 0] : [1, progress.t >= RIGHT_FLOOD_T ? 1 : 0];
/** What the screen shows: portrait triptych, chart pair, or (no media) the typographic plate. */
export const screenKind = (project: Pick<BillboardProject, 'shots'>): 'triptych' | 'pair' | 'plate' =>
project.shots.length === 0 ? 'plate' : project.shots.every((shot) => shot.kind === 'chart') ? 'pair' : 'triptych';

View File

@@ -0,0 +1,618 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types';
import {
billboardProgress,
floodTargets,
readBillboard,
screenKind,
type BillboardProgress,
type BillboardProject,
} from '@/journey/billboard-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { buildsPresence, stepDistrictRise } from './districts';
import { BILLBOARD_NUDGE } from './composition';
import { BILLBOARD_ACCENT } from './palette';
/**
* DRIVE-IN, scene side. A highway billboard on two stilts stands on the
* right shoulder through Selected builds, rising on the same envelope as the
* chapter's districts and drifting from z -36 to -26 as the chapter is read.
* Its screen carries the case-study media (the gallery's own files). When a
* case study's header crosses the activation line, the left flood switches
* on, then the right (project-local t 0.08), and the screen fades from unlit
* grey to lit over 0.6 s. Scrolling back runs it all backwards.
*
* At 1440 the reading panel is opaque from 5% to 95% of the width, so the
* board shows through the band above the panel as the chapter opens and the
* gaps between case studies, which pass the screen's height exactly when a
* header crosses the line, i.e. when the board relights. Placed right, above
* the gallery column. At 390 it sits right of centre in the gaps.
*
* One mesh, one draw call: steel, accent frame, screen, flood heads and the
* two light cones share a ShaderMaterial with premultiplied blending (opaque
* parts write alpha 1, the cones alpha 0, so they add). The cones come last
* in the index buffer, after the screen they light.
*/
/*
* The world is drawn ~2.3x life size (the road is 20 m wide), so a 9 x 6 m
* roadside board is 22 x 15 m here. It stands 50 m ahead on the right
* shoulder, outside the workshop blocks (x 17), where it fills ~350 px of a
* 1440 x 900 frame between the sun and the right edge. Tall viewports have
* the camera on the centre line and see only 16 m to each side at that
* range, so the board steps in to x 12 at 90% size, 30 m further on.
*/
export const BILLBOARD_X = 24;
export const BILLBOARD_NARROW_X = 12;
export const BILLBOARD_NARROW_SCALE = 0.9;
/** Tall viewports see the board this much further down the road, so it stays inside the frame. */
export const BILLBOARD_NARROW_DZ = -30;
export const BILLBOARD_Z_FROM = -36;
export const BILLBOARD_Z_TO = -26;
/** The face turns this far toward the carriageway (rad). */
const BILLBOARD_YAW = -0.38;
const STILT_W = 1;
export const FRAME_W = 22;
export const FRAME_H = 15;
export const FRAME_TOP = 21;
const STILT_H = FRAME_TOP - FRAME_H + 0.5;
const FRAME_BAR = 0.6;
const SCREEN_W = FRAME_W - 2 * FRAME_BAR;
const SCREEN_H = FRAME_H - 2 * FRAME_BAR;
/** Screen aspect: composed textures are drawn at this ratio, so nothing stretches. */
export const SCREEN_ASPECT = SCREEN_W / SCREEN_H;
const FLOOD_X = 6.4;
const HEAD = { y: FRAME_TOP + 1.1, z: 3.6 };
const CONE_AIM = { y: FRAME_TOP - FRAME_H * 0.78, z: 0.25, x: 5.2 };
/** The warm pool the board throws on the shoulder (x across, z along the road). */
const POOL = { w: FRAME_W * 1.5, l: FRAME_W * 0.8, z: 4, y: 1.4 };
/** Seconds. */
export const BILLBOARD_TIMING = {
lit: 0.6,
/** A new project: the old one drops to grey this fast before the swap. */
swap: 0.15,
flood: 0.08,
zoom: 0.35,
flicker: 0.12,
/** Flicker at most this often (2 Hz cap, photosensitivity). */
flickerGap: 0.5,
shimmer: 0.8,
shimmerGap: 2,
} as const;
/** Flood intensity = base + PULSE_GAIN * smoothed bass pulse (full tier only). */
export const PULSE_GAIN = 0.35;
export const FLICKER_THRESHOLD = 0.62;
export const SHIMMER_ENERGY = 0.7;
/** The floods stay off until the board has risen this far (the districts' envelope takes ~2.6 s). */
export const STANDING = 0.9;
/** Texture edge: high tier 1024 px, medium/low 512. */
export const textureSize = (tier: 'high' | 'medium' | 'low'): number => (tier === 'high' ? 1024 : 512);
const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
const toward = (value: number, target: number, step: number): number =>
value < target ? Math.min(target, value + step) : Math.max(target, value - step);
const ease = (t: number): number => t * t * (3 - 2 * t);
/** Where the board stands along the road: it drifts toward the camera as the chapter is read. */
export const billboardZ = (p: number): number =>
THREE.MathUtils.lerp(BILLBOARD_Z_FROM, BILLBOARD_Z_TO, clamp01(p));
/** Start building textures once the chapter is within one viewport of the viewport. */
export function billboardNear(snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>): boolean {
const id = snapshot.activeSectionId;
if (id === 'selected-builds' || id === 'additional-work') return true;
const next = snapshot.sectionActivation.nextHeadingOffsetVh;
return id === 'infrastructure' && next !== null && next < 1.4;
}
/** A rectangle inside the composed screen texture, in UV (origin bottom-left). */
export interface ShotRect { u: number; v: number; w: number; h: number }
export interface ComposedScreen {
canvas: HTMLCanvasElement;
/** Shot id -> where it was drawn; empty for the plate. */
rects: Map<string, ShotRect>;
kind: 'triptych' | 'pair' | 'plate';
}
export interface BillboardIO {
/** Resolve with a drawable image, reject on any failure. */
loadImage(url: string): Promise<CanvasImageSource & { width: number; height: number }>;
/** A 2D canvas, or null when there is none (the screen then stays grey). */
canvas(width: number, height: number): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null;
}
const browserIO: BillboardIO = {
async loadImage(url) {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
return image;
},
canvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
return context === null ? null : { canvas, context };
},
};
const INK = '#0d0915';
/** Charts are drawn at this opacity over the ink so their white stays below the bloom threshold when lit. */
export const CHART_GAIN = 0.68;
/** The typographic plate: project title and its route plate. No imagery. */
export function drawPlate(target: { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D }, project: BillboardProject): ComposedScreen {
const { canvas, context: c } = target;
const { width: w, height: h } = canvas;
const unit = w / 1024;
c.fillStyle = INK;
c.fillRect(0, 0, w, h);
c.fillStyle = BILLBOARD_ACCENT;
c.fillRect(72 * unit, 96 * unit, 120 * unit, 8 * unit);
c.textBaseline = 'alphabetic';
c.font = `700 ${Math.round(44 * unit)}px "Geist Mono Variable", ui-monospace, monospace`;
c.fillText(project.plate, 72 * unit, 180 * unit, w - 144 * unit);
c.fillStyle = '#f6efe9';
let size = 132;
c.font = `650 ${Math.round(size * unit)}px "Geist Variable", sans-serif`;
while (size > 64 && c.measureText(project.title).width > w - 144 * unit) {
size -= 8;
c.font = `650 ${Math.round(size * unit)}px "Geist Variable", sans-serif`;
}
c.fillText(project.title, 72 * unit, h * 0.62, w - 144 * unit);
return { canvas, rects: new Map(), kind: 'plate' };
}
/** Shots laid out side by side at their own aspect (contain), centred. */
export function drawShots(
target: { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D },
project: BillboardProject,
images: ReadonlyArray<{ width: number; height: number } & CanvasImageSource>,
): ComposedScreen {
const { canvas, context: c } = target;
const { width: w, height: h } = canvas;
c.fillStyle = INK;
c.fillRect(0, 0, w, h);
const pad = Math.round(w * 0.03);
const count = images.length;
const cellW = (w - pad * (count + 1)) / count;
const cellH = h - pad * 2;
const rects = new Map<string, ShotRect>();
images.forEach((image, i) => {
const scale = Math.min(cellW / image.width, cellH / image.height);
const dw = image.width * scale;
const dh = image.height * scale;
const x = pad + i * (cellW + pad) + (cellW - dw) / 2;
const y = (h - dh) / 2;
// White charts sit under the bloom threshold once lit; dark phone UIs need no help.
c.globalAlpha = project.shots[i]!.kind === 'chart' ? CHART_GAIN : 1;
c.drawImage(image, x, y, dw, dh);
c.globalAlpha = 1;
rects.set(project.shots[i]!.id, { u: x / w, v: 1 - (y + dh) / h, w: dw / w, h: dh / h });
});
return { canvas, rects, kind: screenKind(project) };
}
/** Composes one project's screen; any failed shot falls back to the plate. Never rejects. */
export async function composeScreen(project: BillboardProject, size: number, io: BillboardIO): Promise<ComposedScreen | null> {
let target: ReturnType<BillboardIO['canvas']>;
try {
target = io.canvas(size, Math.round(size / SCREEN_ASPECT));
} catch {
return null;
}
if (target === null) return null;
if (screenKind(project) !== 'plate') {
try {
const images = await Promise.all(project.shots.map((shot) => io.loadImage(shot.url)));
return drawShots(target, project, images);
} catch {
/* fall through to the plate */
}
}
try {
return drawPlate(target, project);
} catch {
return null;
}
}
/** Zoom-to-fit for one shot: the screen-UV scale that letterboxes it at its own aspect. */
export function zoomFit(rect: ShotRect): [number, number] {
const shot = (rect.w * SCREEN_ASPECT) / rect.h;
return shot < SCREEN_ASPECT ? [SCREEN_ASPECT / shot, 1] : [1, shot / SCREEN_ASPECT];
}
// ---------------------------------------------------------------- geometry
/** Three is untyped here (src/scene/three.d.ts); this is the slice of a geometry the merge reads. */
interface Geometry {
getAttribute(name: string): { array: ArrayLike<number>; count: number };
getIndex(): { array: ArrayLike<number> };
dispose(): void;
}
type Piece = { geometry: Geometry; part: number };
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number): Piece {
return { geometry: new THREE.BoxGeometry(width, height, depth).translate(x, y, z), part };
}
function cone(side: -1 | 1, part: number): Piece {
const head = new THREE.Vector3(side * FLOOD_X, HEAD.y - 0.15, HEAD.z);
const aim = new THREE.Vector3(side * CONE_AIM.x, CONE_AIM.y, CONE_AIM.z);
const axis = aim.clone().sub(head);
const length = axis.length();
axis.normalize();
const geometry = new THREE.CylinderGeometry(0.35, 6, length, 18, 1, true).translate(0, -length / 2, 0);
geometry.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, -1, 0), axis));
geometry.translate(head.x, head.y, head.z);
// uv.y = 0 at the lamp, 1 where the cone meets the screen.
const position = geometry.getAttribute('position');
const uv = geometry.getAttribute('uv');
const point = new THREE.Vector3();
for (let i = 0; i < position.count; i += 1) {
point.fromBufferAttribute(position, i).sub(head);
uv.setY(i, clamp01(point.dot(axis) / length));
}
return { geometry, part };
}
function boardGeometry(): { dispose(): void } {
const mid = FRAME_TOP - FRAME_H / 2;
const pieces: Piece[] = [
box(STILT_W, STILT_H, STILT_W, -7, STILT_H / 2, -0.9, 0),
box(STILT_W, STILT_H, STILT_W, 7, STILT_H / 2, -0.9, 0),
box(FRAME_W, FRAME_H, 0.7, 0, mid, -0.45, 0),
// The catwalk arm the floods hang from.
box(FLOOD_X * 2 + 3, 0.4, 0.4, 0, HEAD.y, HEAD.z - 0.35, 0),
box(0.35, 0.35, HEAD.z, -FLOOD_X, HEAD.y - 0.25, HEAD.z / 2 - 0.5, 0),
box(0.35, 0.35, HEAD.z, FLOOD_X, HEAD.y - 0.25, HEAD.z / 2 - 0.5, 0),
// The accent frame.
box(FRAME_W, FRAME_BAR, 1, 0, FRAME_TOP - FRAME_BAR / 2, 0, 1),
box(FRAME_W, FRAME_BAR, 1, 0, FRAME_TOP - FRAME_H + FRAME_BAR / 2, 0, 1),
box(FRAME_BAR, FRAME_H, 1, -FRAME_W / 2 + FRAME_BAR / 2, mid, 0, 1),
box(FRAME_BAR, FRAME_H, 1, FRAME_W / 2 - FRAME_BAR / 2, mid, 0, 1),
{ geometry: new THREE.PlaneGeometry(SCREEN_W, SCREEN_H).translate(0, mid, 0.12), part: 2 },
box(1.7, 1, 1.1, -FLOOD_X, HEAD.y, HEAD.z, 3),
box(1.7, 1, 1.1, FLOOD_X, HEAD.y, HEAD.z, 4),
// Last: the light adds over what it lights. The pool on the shoulder, then the cones.
{ geometry: new THREE.PlaneGeometry(POOL.w, POOL.l).rotateX(-Math.PI / 2).translate(0, POOL.y, POOL.z), part: 7 },
cone(-1, 5),
cone(1, 6),
];
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const { geometry, part } of pieces) {
const offset = positions.length / 3;
const position = geometry.getAttribute('position');
positions.push(...Array.from(position.array));
normals.push(...Array.from(geometry.getAttribute('normal').array));
uvs.push(...Array.from(geometry.getAttribute('uv').array));
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(geometry.getIndex().array, (i) => i + offset));
geometry.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
// ---------------------------------------------------------------- world
export interface BillboardFrame {
section: SectionId;
/** Selected builds chapter progress, null outside the chapter. */
p: number | null;
direction: -1 | 0 | 1;
near: boolean;
/** Smoothed bass pulse 0..1 (0 with audio off). */
pulse: number;
/** Drive energy 0..1. */
energy: number;
/** html[data-billboard-focus]: the hovered gallery shot. */
focus: string | undefined;
/** Tall viewport (camera on the centre line): the board steps in and shrinks. */
narrow?: boolean;
deltaSeconds: number;
}
export interface BillboardState {
rise: number;
z: number;
progress: BillboardProgress;
/** Project whose texture is on the screen. */
shown: string | null;
lit: number;
floods: [number, number];
zoom: number;
nudge: number;
flickers: number;
shimmers: number;
textures: number;
}
export interface BillboardWorld {
readonly mesh: { visible: boolean; name: string };
/** Returns the camera nudge, 0..1 (eased). Never throws. */
update(frame: BillboardFrame): number;
/** Test and instrumentation hook. */
inspect(): BillboardState;
}
export interface BillboardOptions {
/** 'full': pulse, flicker, shimmer, hover zoom. 'lite' (low tier, touch): one static flood. */
mode: 'full' | 'lite';
textureSize: number;
io?: BillboardIO;
}
export function createBillboard(resources: SceneResourceBag, options: BillboardOptions): BillboardWorld {
const full = options.mode === 'full';
const io = options.io ?? browserIO;
const geometry = resources.own('geometry', boardGeometry());
const uniforms = {
uRise: { value: 0 },
uScreen: { value: null as unknown },
uLit: { value: 0 },
uFlood: { value: new THREE.Vector2() },
uHeads: { value: 1 },
uAccent: { value: new THREE.Color(BILLBOARD_ACCENT) },
uZoom: { value: 0 },
uZoomRect: { value: new THREE.Vector4(0, 0, 1, 1) },
uZoomFit: { value: new THREE.Vector2(1, 1) },
uShimmer: { value: 0 },
};
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
attribute float aPart;
uniform float uRise;
varying float vPart;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
float rise = uRise * uRise * (3.0 - 2.0 * uRise);
vec3 p = vec3(position.x, position.y * rise - (1.0 - rise) * 0.4, position.z);
vec4 world = modelMatrix * vec4(p, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix) * normal);
vPart = aPart;
vUv = uv;
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform float uRise;
uniform sampler2D uScreen;
uniform float uLit;
uniform vec2 uFlood;
uniform float uHeads;
uniform vec3 uAccent;
uniform float uZoom;
uniform vec4 uZoomRect;
uniform vec2 uZoomFit;
uniform float uShimmer;
varying float vPart;
varying vec2 vUv;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
// Screen-door rise, like the districts: never a translucent sheet.
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0;
if (smoothstep(0.0, 0.25, uRise) < threshold) discard;
vec3 n = normalize(vNormal);
vec3 toCamera = normalize(cameraPosition - vWorld);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float flood = 0.5 * (uFlood.x + uFlood.y);
vec3 warm = vec3(1.0, 0.88, 0.7);
vec3 color;
float alpha = 1.0;
if (vPart < 0.5) {
color = vec3(0.03, 0.026, 0.045) + vec3(0.08, 0.07, 0.11) * key + warm * 0.05 * flood * max(0.0, n.y);
color += uAccent * 0.05 * pow(1.0 - abs(dot(n, toCamera)), 3.0);
} else if (vPart < 1.5) {
color = uAccent * (0.55 + 0.9 * flood);
// Drive-energy shimmer: one bright band runs round the frame.
float band = 1.0 - smoothstep(0.0, 0.08, abs(fract((vWorld.x + vWorld.y) * 0.05) - (1.0 - uShimmer)));
color += vec3(1.0, 0.95, 0.9) * band * uShimmer * 1.6;
} else if (vPart < 2.5) {
vec4 frame = texture2D(uScreen, vUv);
vec2 q = (vUv - 0.5) * uZoomFit + 0.5;
float inside = step(0.0, q.x) * step(q.x, 1.0) * step(0.0, q.y) * step(q.y, 1.0);
vec4 shot = texture2D(uScreen, uZoomRect.xy + clamp(q, 0.0, 1.0) * uZoomRect.zw) * inside;
vec3 image = mix(frame.rgb, shot.rgb, uZoom);
// Top-down floods: the left lamp lights the left of the face, the right the right.
float lamps = uFlood.x * smoothstep(0.95, 0.15, vUv.x) + uFlood.y * smoothstep(0.05, 0.85, vUv.x);
float fall = mix(0.7, 1.0, vUv.y);
// Full flood ~1.0, a bass hit ~1.25; charts are drawn at CHART_GAIN so their white stays below blow-out.
vec3 lit = image * (0.25 + 0.8 * min(1.35, lamps) * fall);
vec3 grey = vec3(0.13, 0.13, 0.15) * (0.7 + 0.3 * vUv.y) + warm * 0.08 * lamps * fall;
color = mix(grey, lit, uLit);
} else if (vPart < 4.5) {
float on = vPart < 3.5 ? uFlood.x : uFlood.y;
color = mix(vec3(0.08, 0.07, 0.09), warm * 3.2, on * uHeads);
} else if (vPart > 6.5) {
// The pool on the shoulder: the lamps' spill and the lit screen's glow.
vec2 d = (vUv - 0.5) * vec2(2.0, 2.2);
float pool = pow(max(0.0, 1.0 - length(d)), 1.6);
color = mix(warm, uAccent, 0.25) * pool * (1.3 * flood * uHeads + 0.7 * uLit);
alpha = 0.0;
} else {
// The cones add: bright at the lamp, soft at the edges, gone at the face.
float on = (vPart < 5.5 ? uFlood.x : uFlood.y) * uHeads;
float facing = pow(abs(dot(n, toCamera)), 1.6);
float along = (1.0 - vUv.y * 0.75) * smoothstep(1.0, 0.85, vUv.y);
color = warm * 0.32 * on * facing * along;
alpha = 0.0;
}
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: true,
blending: THREE.CustomBlending,
blendSrc: THREE.OneFactor,
blendDst: THREE.OneMinusSrcAlphaFactor,
fog: false,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'drive-in-billboard';
mesh.frustumCulled = false;
mesh.rotation.y = BILLBOARD_YAW;
mesh.position.set(BILLBOARD_X, 0, BILLBOARD_Z_FROM);
mesh.visible = false;
const screens = new Map<string, { texture: unknown; rects: Map<string, ShotRect> }>();
let loading = false;
let rise = 0;
let lastP = 0;
let shown: string | null = null;
let lit = 0;
const floods: [number, number] = [0, 0];
let zoom = 0;
let nudgeRun = 0;
let pulseWasHigh = false;
let sinceFlicker = Number.POSITIVE_INFINITY;
let flicker = 0;
let flickers = 0;
let energyWasHigh = false;
let sinceShimmer = Number.POSITIVE_INFINITY;
let shimmer = 0;
let shimmers = 0;
let warmFrames = 2;
let progress: BillboardProgress = { activeProjectId: null, index: -1, t: 0, direction: 0 };
const load = (projects: readonly BillboardProject[]): void => {
loading = true;
for (const project of projects) {
void composeScreen(project, options.textureSize, io).then((screen) => {
if (screen === null) return;
const texture = resources.own('texture', new THREE.CanvasTexture(screen.canvas));
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
screens.set(project.id, { texture, rects: screen.rects });
}, () => undefined);
}
};
const step = (frame: BillboardFrame): number => {
const dt = Math.max(0, frame.deltaSeconds);
const feed = readBillboard();
if (!loading && frame.near && feed.projects.length > 0) load(feed.projects);
// Up with the workshop, and already standing as the chapter heading
// approaches so the entry band shows it whole; down after the chapter.
const presence = frame.section === 'infrastructure' && frame.near ? 1 : buildsPresence(frame.section);
rise = stepDistrictRise(rise, presence, dt);
if (frame.p !== null) lastP = frame.p;
else if (frame.section !== 'selected-builds') lastP = frame.section === 'additional-work' || frame.section === 'toolkit' || frame.section === 'contact' ? 1 : 0;
const narrow = frame.narrow === true;
mesh.position.z = billboardZ(lastP) + (narrow ? BILLBOARD_NARROW_DZ : 0);
mesh.position.x = narrow ? BILLBOARD_NARROW_X : BILLBOARD_X;
mesh.scale.setScalar(narrow ? BILLBOARD_NARROW_SCALE : 1);
const standing = rise > STANDING;
// On the approach the first case study already has the board (its stop is 0).
const p = frame.p ?? (presence === 1 && frame.section === 'infrastructure' ? 0 : null);
progress = billboardProgress(standing ? p : null, feed.stops, feed.projects.map((project) => project.id), frame.direction);
// Floods: off while rising; lite keeps one steady lamp.
const [left, right] = full ? floodTargets(progress) : [standing ? 1 : 0, 0];
floods[0] = toward(floods[0], left, dt / BILLBOARD_TIMING.flood);
floods[1] = toward(floods[1], right, dt / BILLBOARD_TIMING.flood);
// Screen: drop the old project to grey, swap, light the new one over 0.6 s.
const wanted = progress.activeProjectId;
if (wanted !== shown && wanted !== null) {
lit = toward(lit, 0, dt / BILLBOARD_TIMING.swap);
if (lit === 0 && screens.has(wanted)) shown = wanted;
} else {
lit = toward(lit, wanted !== null && screens.has(wanted) ? 1 : 0, dt / BILLBOARD_TIMING.lit);
}
const screen = shown === null ? undefined : screens.get(shown);
uniforms.uScreen.value = screen?.texture ?? null;
// Hover zoom (fine pointer, full tier): crossfade to the hovered shot.
const rect = full && frame.focus !== undefined ? screen?.rects.get(frame.focus) : undefined;
if (rect !== undefined) {
uniforms.uZoomRect.value.set(rect.u, rect.v, rect.w, rect.h);
uniforms.uZoomFit.value.set(...zoomFit(rect));
}
zoom = toward(zoom, rect !== undefined && lit > 0 ? 1 : 0, dt / BILLBOARD_TIMING.zoom);
nudgeRun = toward(nudgeRun, wanted !== null ? 1 : 0, dt / BILLBOARD_NUDGE.seconds);
// Sound reward: the floods breathe with the bass and flicker once on a hit.
const pulse = full ? clamp01(frame.pulse) : 0;
sinceFlicker += dt;
const high = pulse > FLICKER_THRESHOLD;
if (high && !pulseWasHigh && sinceFlicker >= BILLBOARD_TIMING.flickerGap && floods[0] + floods[1] > 0) {
flicker = 1;
sinceFlicker = 0;
flickers += 1;
}
pulseWasHigh = high;
flicker = Math.max(0, flicker - dt / BILLBOARD_TIMING.flicker);
sinceShimmer += dt;
const charged = full && frame.energy >= SHIMMER_ENERGY;
if (charged && !energyWasHigh && sinceShimmer >= BILLBOARD_TIMING.shimmerGap && standing) {
shimmer = 1;
sinceShimmer = 0;
shimmers += 1;
}
energyWasHigh = charged;
shimmer = Math.max(0, shimmer - dt / BILLBOARD_TIMING.shimmer);
const gain = 1 + PULSE_GAIN * pulse;
uniforms.uRise.value = rise;
uniforms.uLit.value = ease(lit);
uniforms.uFlood.value.set(floods[0] * gain, floods[1] * gain);
uniforms.uHeads.value = 1 - 0.75 * flicker;
uniforms.uZoom.value = ease(zoom);
uniforms.uShimmer.value = shimmer;
const warming = warmFrames > 0;
if (warming) warmFrames -= 1;
mesh.visible = warming || rise > 0.002;
return ease(nudgeRun);
};
return {
mesh,
update(frame) {
try {
return step(frame);
} catch {
mesh.visible = false;
return 0;
}
},
inspect: () => ({
rise,
z: mesh.position.z,
progress,
shown,
lit,
floods: [floods[0], floods[1]],
zoom,
nudge: ease(nudgeRun),
flickers,
shimmers,
textures: screens.size,
}),
};
}

View File

@@ -87,3 +87,10 @@ export const CHAPTER_COMPOSITIONS: Readonly<
shift: 0.24,
},
};
/**
* DRIVE-IN: while a case study holds the billboard the lens leans toward it.
* Added on top of the chapter's composition, eased in over 0.9 s and back
* out when the board goes dark, so it is fully reversible.
*/
export const BILLBOARD_NUDGE = { targetZ: -6, shift: 0.03, seconds: 0.9 } as const;

View File

@@ -51,6 +51,19 @@ export const DISTRICT_PRESENCE: Readonly<
arch: { contact: 1 },
};
/**
* The rise envelope every district piece follows toward its chapter
* presence: up at 0.9/s, down at 1.4/s, snapping within 0.002. The DRIVE-IN
* billboard rises on the same curve as the Selected builds districts.
*/
export function stepDistrictRise(rise: number, target: number, dt: number): number {
const next = rise + (target - rise) * (1 - Math.exp(-dt * (target > rise ? 0.9 : 1.4)));
return Math.abs(target - next) < 0.002 ? target : next;
}
/** How strongly a chapter raises the Selected builds district (the workshop). */
export const buildsPresence = (section: SectionId): number => DISTRICT_PRESENCE.workshop[section] ?? 0;
const span = (count: number, from: number, to: number) =>
Array.from({ length: count }, (_, i) => from + ((to - from) * i) / count);
@@ -372,10 +385,7 @@ export function createDistricts(
targetAccent.set(signAccentFor(section));
shared.uAccent.value.lerp(targetAccent, 1 - Math.exp(-dt * 2.5));
for (const [piece, entry] of state) {
const target = DISTRICT_PRESENCE[piece][section] ?? 0;
const rate = target > entry.rise.value ? 0.9 : 1.4;
entry.rise.value += (target - entry.rise.value) * (1 - Math.exp(-dt * rate));
if (Math.abs(target - entry.rise.value) < 0.002) entry.rise.value = target;
entry.rise.value = stepDistrictRise(entry.rise.value, DISTRICT_PRESENCE[piece][section] ?? 0, dt);
const visible = warming || entry.rise.value > 0.002;
for (const mesh of entry.meshes) mesh.visible = visible;
}

View File

@@ -37,6 +37,8 @@ import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { createMilestones } from '@/scene/world/milestones';
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
import { BILLBOARD_NUDGE } from '@/scene/world/composition';
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
@@ -174,7 +176,15 @@ export async function createProceduralSceneGeneration(
// MILE MARKERS: kilometre posts roll past through Experience and Profile.
// Low tier and touch draw them without beam, pulse or flash.
const milestones = createMilestones(context.resources, maintenanceTier === 'full');
// DRIVE-IN: the Selected builds billboard. Low tier and touch: one steady
// flood, no pulse, flicker or hover zoom, 512 px screens.
const billboard = createBillboard(context.resources, {
mode: maintenanceTier,
textureSize: textureSize(context.quality),
});
let billboardKey = '';
scene.add(
billboard.mesh,
maintenance.group,
milestones.mesh,
milestones.pool,
@@ -366,6 +376,19 @@ export async function createProceduralSceneGeneration(
musicPulse,
deltaSeconds,
);
const billboardNudge = billboard.update({
section: snapshot.activeSectionId,
p: snapshot.activeSectionId === 'selected-builds'
? sectionLocalProgress(snapshot, 'selected-builds')
: null,
direction: snapshot.direction,
near: billboardNear(snapshot),
pulse: musicPulse,
energy: overdrive,
focus: document.documentElement.dataset.billboardFocus,
narrow: camera.aspect <= 1.18,
deltaSeconds,
});
// END OF SHIFT: parked at the destination, the night ends. One scalar
// drives it all; leaving rewinds it as fast as the outro returns.
const dawn = easeInOutCubic(outroProgress);
@@ -511,7 +534,7 @@ export async function createProceduralSceneGeneration(
baseComposition.targetZ,
upcoming.targetZ,
anticipation,
),
) + BILLBOARD_NUDGE.targetZ * billboardNudge,
fov: THREE.MathUtils.lerp(
baseComposition.fov,
upcoming.fov,
@@ -521,7 +544,7 @@ export async function createProceduralSceneGeneration(
baseComposition.shift,
upcoming.shift,
anticipation,
),
) + BILLBOARD_NUDGE.shift * billboardNudge,
};
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
@@ -682,6 +705,16 @@ export async function createProceduralSceneGeneration(
dawnState = nextDawnState;
document.documentElement.dataset.dawn = nextDawnState;
}
if (TEST_BUILD) {
const board = billboard.inspect();
const key = `${board.progress.activeProjectId}|${board.shown}|${board.floods.map(Math.round).join()}|${Math.round(board.lit * 10)}|${Math.round(board.rise * 10)}|${Math.round(board.zoom * 10)}|${board.flickers}|${board.shimmers}|${board.textures}`;
if (key !== billboardKey) {
billboardKey = key;
window.dispatchEvent(new CustomEvent('portfolio-billboard-frame', {
detail: { now, activeSectionId: snapshot.activeSectionId, direction: snapshot.direction, ...board },
}));
}
}
if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height);
if (presentation !== null) {

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
export interface ChapterPaletteDefinition {
sky: number;
@@ -170,6 +171,9 @@ export function engineOffStarScale(engineOff: number, dawn: number): number {
* (about 55%) while the maintenance window is open. A tint, not a chapter.
*/
export const STORM_TINT = 0x060c26;
/** DRIVE-IN: the billboard frame wears the Selected builds chapter accent. */
export const BILLBOARD_ACCENT: string = SIGN_ACCENTS['selected-builds'];
/** Star visibility while the storm is in. */
export const STORM_STAR_SCALE = 0.4;

19
src/styles/billboard.css Normal file
View File

@@ -0,0 +1,19 @@
/* DRIVE-IN: the case study holding the billboard is floodlit from above.
Also the whole effect when there is no scene (reduced motion, static, failed).
html[data-billboard-active] and [data-billboard-lit] exist only after mount. */
html[data-billboard-active] #selected-builds .project-media-gallery,
html[data-billboard-active] #selected-builds .project-case-study__header {
transition: box-shadow 600ms ease, border-color 600ms ease;
}
html[data-billboard-active] [data-billboard-lit] .project-media-gallery {
box-shadow:
inset 0 2px 0 color-mix(in srgb, var(--chapter-accent, var(--accent)) 80%, transparent),
inset 0 150px 120px -70px rgb(255 214 168 / 16%);
}
html[data-billboard-active] [data-billboard-lit] .project-case-study__header {
border-inline-start-color: var(--chapter-accent, var(--accent));
}
@media (prefers-reduced-motion: reduce) {
html[data-billboard-active] #selected-builds .project-media-gallery,
html[data-billboard-active] #selected-builds .project-case-study__header { transition: none; }
}

View File

@@ -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)),
};
});
}

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();
});
});