feat: floodlit roadside billboard carries the selected-builds case studies
@@ -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/.
|
||||
|
||||
|
||||
BIN
docs/overnight/round5/before-1440-bare.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/overnight/round5/before-1440-entry.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/overnight/round5/before-390.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/overnight/round5/case0-medication-lit.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/overnight/round5/case1-opinion-lit.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/overnight/round5/case2-footy-lit.webp
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/overnight/round5/entry.webp
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/overnight/round5/gap-1.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/overnight/round5/gap-2.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/overnight/round5/hover-zoom-crop.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/overnight/round5/hover-zoom.webp
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/overnight/round5/mobile-390-gap-sweep.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/overnight/round5/mobile-390.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/overnight/round5/reduced-motion.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/overnight/round5/reverse.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
38
src/composables/useBillboard.ts
Normal 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; });
|
||||
}
|
||||
47
src/journey/billboard-feed.ts
Normal 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;
|
||||
56
src/journey/billboard-progress.ts
Normal 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';
|
||||
618
src/scene/world/billboard.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||