feat: floodlit roadside billboard carries the selected-builds case studies
This commit is contained in:
47
src/journey/billboard-feed.ts
Normal file
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
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';
|
||||
Reference in New Issue
Block a user