diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index 3c32e53..c275a64 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -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/. + diff --git a/docs/overnight/round5/before-1440-bare.webp b/docs/overnight/round5/before-1440-bare.webp new file mode 100644 index 0000000..9205d3c Binary files /dev/null and b/docs/overnight/round5/before-1440-bare.webp differ diff --git a/docs/overnight/round5/before-1440-entry.webp b/docs/overnight/round5/before-1440-entry.webp new file mode 100644 index 0000000..cd3a5f2 Binary files /dev/null and b/docs/overnight/round5/before-1440-entry.webp differ diff --git a/docs/overnight/round5/before-390.webp b/docs/overnight/round5/before-390.webp new file mode 100644 index 0000000..8c800d8 Binary files /dev/null and b/docs/overnight/round5/before-390.webp differ diff --git a/docs/overnight/round5/case0-medication-lit.webp b/docs/overnight/round5/case0-medication-lit.webp new file mode 100644 index 0000000..ddad892 Binary files /dev/null and b/docs/overnight/round5/case0-medication-lit.webp differ diff --git a/docs/overnight/round5/case1-opinion-lit.webp b/docs/overnight/round5/case1-opinion-lit.webp new file mode 100644 index 0000000..6f9d8ef Binary files /dev/null and b/docs/overnight/round5/case1-opinion-lit.webp differ diff --git a/docs/overnight/round5/case2-footy-lit.webp b/docs/overnight/round5/case2-footy-lit.webp new file mode 100644 index 0000000..c2bacde Binary files /dev/null and b/docs/overnight/round5/case2-footy-lit.webp differ diff --git a/docs/overnight/round5/entry.webp b/docs/overnight/round5/entry.webp new file mode 100644 index 0000000..387a1fc Binary files /dev/null and b/docs/overnight/round5/entry.webp differ diff --git a/docs/overnight/round5/gap-1.webp b/docs/overnight/round5/gap-1.webp new file mode 100644 index 0000000..9c3c43a Binary files /dev/null and b/docs/overnight/round5/gap-1.webp differ diff --git a/docs/overnight/round5/gap-2.webp b/docs/overnight/round5/gap-2.webp new file mode 100644 index 0000000..ee68a2d Binary files /dev/null and b/docs/overnight/round5/gap-2.webp differ diff --git a/docs/overnight/round5/hover-zoom-crop.webp b/docs/overnight/round5/hover-zoom-crop.webp new file mode 100644 index 0000000..b4ba75a Binary files /dev/null and b/docs/overnight/round5/hover-zoom-crop.webp differ diff --git a/docs/overnight/round5/hover-zoom.webp b/docs/overnight/round5/hover-zoom.webp new file mode 100644 index 0000000..fd695bb Binary files /dev/null and b/docs/overnight/round5/hover-zoom.webp differ diff --git a/docs/overnight/round5/mobile-390-gap-sweep.webp b/docs/overnight/round5/mobile-390-gap-sweep.webp new file mode 100644 index 0000000..bfbb2eb Binary files /dev/null and b/docs/overnight/round5/mobile-390-gap-sweep.webp differ diff --git a/docs/overnight/round5/mobile-390.webp b/docs/overnight/round5/mobile-390.webp new file mode 100644 index 0000000..180a075 Binary files /dev/null and b/docs/overnight/round5/mobile-390.webp differ diff --git a/docs/overnight/round5/reduced-motion.webp b/docs/overnight/round5/reduced-motion.webp new file mode 100644 index 0000000..329a6bc Binary files /dev/null and b/docs/overnight/round5/reduced-motion.webp differ diff --git a/docs/overnight/round5/reverse.webp b/docs/overnight/round5/reverse.webp new file mode 100644 index 0000000..0ba3705 Binary files /dev/null and b/docs/overnight/round5/reverse.webp differ diff --git a/src/App.vue b/src/App.vue index ade4e07..0c037dc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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 }>(); diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 8ad8ed8..cc72621 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -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(() => { diff --git a/src/composables/useBillboard.ts b/src/composables/useBillboard.ts new file mode 100644 index 0000000..cd44cd2 --- /dev/null +++ b/src/composables/useBillboard.ts @@ -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>, list: () => readonly PublicProject[], manifest: PublicAssetManifest, exit: string): void { + const stops = shallowRef([]); + const html = (): DOMStringMap => document.documentElement.dataset; + const top = (el: Element | null | undefined): number => (el?.getBoundingClientRect().top ?? 0) + scrollY; + const cases = (): HTMLElement[] => [...document.querySelectorAll('#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; }); +} diff --git a/src/journey/billboard-feed.ts b/src/journey/billboard-feed.ts new file mode 100644 index 0000000..b524b3f --- /dev/null +++ b/src/journey/billboard-feed.ts @@ -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; diff --git a/src/journey/billboard-progress.ts b/src/journey/billboard-progress.ts new file mode 100644 index 0000000..3417d96 --- /dev/null +++ b/src/journey/billboard-progress.ts @@ -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): 'triptych' | 'pair' | 'plate' => + project.shots.length === 0 ? 'plate' : project.shots.every((shot) => shot.kind === 'chart') ? 'pair' : 'triptych'; diff --git a/src/scene/world/billboard.ts b/src/scene/world/billboard.ts new file mode 100644 index 0000000..3a9436d --- /dev/null +++ b/src/scene/world/billboard.ts @@ -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): 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; + kind: 'triptych' | 'pair' | 'plate'; +} + +export interface BillboardIO { + /** Resolve with a drawable image, reject on any failure. */ + loadImage(url: string): Promise; + /** 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(); + 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 { + let target: ReturnType; + 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; count: number }; + getIndex(): { array: ArrayLike }; + 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 + #include + } + `, + 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 }>(); + 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, + }), + }; +} diff --git a/src/scene/world/composition.ts b/src/scene/world/composition.ts index 089caa9..11a5a69 100644 --- a/src/scene/world/composition.ts +++ b/src/scene/world/composition.ts @@ -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; diff --git a/src/scene/world/districts.ts b/src/scene/world/districts.ts index f136aca..ea6b2d8 100644 --- a/src/scene/world/districts.ts +++ b/src/scene/world/districts.ts @@ -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; } diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 3754eb3..d0f69fc 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -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) { diff --git a/src/scene/world/palette.ts b/src/scene/world/palette.ts index f3c93b4..aeb3ac8 100644 --- a/src/scene/world/palette.ts +++ b/src/scene/world/palette.ts @@ -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; diff --git a/src/styles/billboard.css b/src/styles/billboard.css new file mode 100644 index 0000000..e5469f9 --- /dev/null +++ b/src/styles/billboard.css @@ -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; } +} diff --git a/tests/helpers/scene-instrumentation.ts b/tests/helpers/scene-instrumentation.ts index 5893836..8cecdc5 100644 --- a/tests/helpers/scene-instrumentation.ts +++ b/tests/helpers/scene-instrumentation.ts @@ -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 { clearColors: [], unhandledRejections: [], signFrames: [], + billboardFrames: [], animationFrameIds: new Set(), audioContexts: [], }; @@ -166,6 +186,11 @@ export async function installSceneInstrumentation(page: Page): Promise { if (!detail || state.signFrames.length >= 600) return; state.signFrames.push(structuredClone(detail)); }); + window.addEventListener('portfolio-billboard-frame', (event) => { + const detail = (event as CustomEvent).detail; + if (!detail || state.billboardFrames.length >= 600) return; + state.billboardFrames.push(structuredClone(detail)); + }); }); } @@ -182,6 +207,7 @@ export async function readSceneInstrumentation(page: Page): Promise structuredClone(frame)), + billboardFrames: state.billboardFrames.map((frame) => structuredClone(frame)), }; }); } diff --git a/tests/unit/AppShell.test.ts b/tests/unit/AppShell.test.ts index aba75e3..63cdc31 100644 --- a/tests/unit/AppShell.test.ts +++ b/tests/unit/AppShell.test.ts @@ -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); diff --git a/tests/unit/billboard-progress.test.ts b/tests/unit/billboard-progress.test.ts new file mode 100644 index 0000000..226a33a --- /dev/null +++ b/tests/unit/billboard-progress.test.ts @@ -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] }); + }); +}); diff --git a/tests/unit/billboard.test.ts b/tests/unit/billboard.test.ts new file mode 100644 index 0000000..d6d770b --- /dev/null +++ b/tests/unit/billboard.test.ts @@ -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 => { + 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 => ({ + 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, 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(); + }); +});