+ {STANDARD_CHIPS.map(({ id, label, href, file }) => (
+ // The wrapper is the chip's size, so the scene centers the chip
+ // itself on its anchor. The file name hangs off to its right, or
+ // sits above it on narrower screens, where it would run into the
+ // next chip; left-aligned there so the GBFS chip's label, near the
+ // scene's left edge, isn't clipped.
+ {
+ elements[id] = element;
+ }}
+ style={{
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ // Hidden until the scene places it.
+ opacity: 0,
+ visibility: 'hidden',
+ willChange: 'transform, opacity',
+ }}
+ >
+
+
+ {file}
+
+
+ ))}
+
+ );
+}
diff --git a/src/app/[locale]/components/hero-animation/accessibility-model.ts b/src/app/[locale]/components/hero-animation/accessibility-model.ts
new file mode 100644
index 00000000..981e698f
--- /dev/null
+++ b/src/app/[locale]/components/hero-animation/accessibility-model.ts
@@ -0,0 +1,472 @@
+// Pure model behind the accessibility surfaces. Kept free of three.js so it
+// can be unit-tested and shared by the shader uniforms and CPU-side overlays.
+//
+// Travel time T(x,y,j) is the fastest of:
+// - walking straight from (x,y) to opportunity j, or
+// - walking to any station k, riding the line, and walking from the
+// alighting station to j (precomputed per (j, k) as an "egress" cost).
+
+export type SurfaceMode = 'exponential' | 'cumulative';
+
+export interface Station {
+ x: number;
+ y: number;
+ line: number;
+ /** Distance along the line from its first station. */
+ chainage: number;
+}
+
+export interface Opportunity {
+ x: number;
+ y: number;
+ /** O_j: number of opportunities (jobs, services, ...) at this location. */
+ weight: number;
+}
+
+export interface ModelState {
+ opportunities: Opportunity[];
+ /** egress[j * stations.length + k]: min cost from boarding at k to reach j. */
+ egress: Float32Array;
+ beta: number;
+ tau: number;
+ walkSpeed: number;
+}
+
+/** User-adjustable inputs; see the controls panel. */
+export interface ModelParams {
+ /** When true, beta oscillates on its own and `beta` is ignored. */
+ autoBeta: boolean;
+ beta: number;
+ /** When true, tau oscillates on its own and `tau` is ignored. */
+ autoTau: boolean;
+ tau: number;
+ walkSpeed: number;
+ transitSpeed: number;
+ boardingPenalty: number;
+ /**
+ * When true, each line's boarding wait rises and falls as if service
+ * frequency changed over the day (around `boardingPenalty`).
+ */
+ autoService: boolean;
+ transitEnabled: boolean;
+ /** Montréal panel: real travel times are in minutes, so beta is per min. */
+ autoMontrealBeta: boolean;
+ montrealBeta: number;
+ /** Show transit lines, stations and opportunity markers on the surfaces. */
+ showHelpers: boolean;
+}
+
+export const DEFAULT_PARAMS: ModelParams = {
+ autoBeta: true,
+ beta: 1.3,
+ autoTau: true,
+ tau: 2.8,
+ walkSpeed: 1,
+ transitSpeed: 4.5,
+ boardingPenalty: 0.6,
+ autoService: true,
+ transitEnabled: true,
+ autoMontrealBeta: true,
+ montrealBeta: 0.08,
+ showHelpers: true,
+};
+
+/** Stand-in for "unreachable" that stays finite inside the shader. */
+const NO_TRANSIT_COST = 1e4;
+
+export function autoBeta(t: number): number {
+ return 1.3 + 0.3 * Math.sin(t * 0.09);
+}
+
+/** Relative swing of the boarding wait when `autoService` is on. */
+const SERVICE_SWING = 0.6;
+
+/**
+ * Boarding wait on `line` at time `t`. The waiting part of a trip is about
+ * half the headway, so busier service means a shorter wait. The two lines
+ * run out of phase: one is well served while the other thins out.
+ */
+export function boardingWait(
+ line: number,
+ t: number,
+ params: ModelParams,
+): number {
+ if (!params.autoService) return params.boardingPenalty;
+ const swing = SERVICE_SWING * Math.sin(t * 0.1 + line * Math.PI);
+ return params.boardingPenalty * (1 + swing);
+}
+
+export function autoTau(t: number): number {
+ return 2.8 + 0.9 * Math.sin(t * 0.07 + 1.1);
+}
+
+export function autoMontrealBeta(t: number): number {
+ return 0.08 + 0.035 * Math.sin(t * 0.08 + 0.4);
+}
+
+export function montrealBetaAt(t: number, params: ModelParams): number {
+ return params.autoMontrealBeta ? autoMontrealBeta(t) : params.montrealBeta;
+}
+
+export const HALF_EXTENT = 5;
+export const OPPORTUNITY_COUNT = 12;
+
+const LOOP_RADIUS = 2.9;
+const LOOP_STATION_COUNT = 8;
+const LOOP_LENGTH = 2 * Math.PI * LOOP_RADIUS;
+
+function buildStations(): Station[] {
+ const loop: Station[] = Array.from({ length: LOOP_STATION_COUNT }, (_, i) => {
+ const angle = (i / LOOP_STATION_COUNT) * Math.PI * 2 + 0.2;
+ return {
+ x: Math.cos(angle) * LOOP_RADIUS,
+ y: Math.sin(angle) * LOOP_RADIUS,
+ line: 0,
+ chainage: (i / LOOP_STATION_COUNT) * LOOP_LENGTH,
+ };
+ });
+ const crosstownStart = { x: -4.2, y: -3.4 };
+ const crosstownEnd = { x: 4.0, y: 3.6 };
+ const crosstownCount = 5;
+ const crosstownLength = Math.hypot(
+ crosstownEnd.x - crosstownStart.x,
+ crosstownEnd.y - crosstownStart.y,
+ );
+ const crosstown: Station[] = Array.from(
+ { length: crosstownCount },
+ (_, i) => {
+ const f = i / (crosstownCount - 1);
+ return {
+ x: crosstownStart.x + (crosstownEnd.x - crosstownStart.x) * f,
+ y: crosstownStart.y + (crosstownEnd.y - crosstownStart.y) * f,
+ line: 1,
+ chainage: crosstownLength * f,
+ };
+ },
+ );
+ return [...loop, ...crosstown];
+}
+
+export const STATIONS: Station[] = buildStations();
+
+/** Polylines (closed for the loop) used to draw the transit lines. */
+export const TRANSIT_LINES: Array<{ points: Array<[number, number]> }> = [
+ {
+ points: Array.from({ length: 97 }, (_, i) => {
+ const angle = (i / 96) * Math.PI * 2;
+ return [Math.cos(angle) * LOOP_RADIUS, Math.sin(angle) * LOOP_RADIUS];
+ }),
+ },
+ {
+ points: Array.from({ length: 49 }, (_, i) => {
+ const first = STATIONS[LOOP_STATION_COUNT];
+ const last = STATIONS[STATIONS.length - 1];
+ const f = i / 48;
+ return [
+ first.x + (last.x - first.x) * f,
+ first.y + (last.y - first.y) * f,
+ ];
+ }),
+ },
+];
+
+function rideTime(
+ a: Station,
+ b: Station,
+ params: ModelParams,
+ wait: number,
+): number {
+ if (a.line !== b.line) return Infinity;
+ let distance = Math.abs(a.chainage - b.chainage);
+ if (a.line === 0) distance = Math.min(distance, LOOP_LENGTH - distance);
+ return distance / params.transitSpeed + wait;
+}
+
+// Deterministic pseudo-random layout so every viewer sees the same city.
+function seededRandom(seed: number): () => number {
+ let s = seed;
+ return (): number => {
+ s = (s * 16807) % 2147483647;
+ return (s - 1) / 2147483646;
+ };
+}
+
+interface OpportunitySeed {
+ x: number;
+ y: number;
+ weight: number;
+ phase: number;
+}
+
+const SEED_GRID_COLS = 4;
+const SEED_GRID_ROWS = 3;
+
+const OPPORTUNITY_SEEDS: OpportunitySeed[] = ((): OpportunitySeed[] => {
+ const rand = seededRandom(20260929);
+ // Jittered 4x3 grid: spread across the whole square so each opportunity
+ // reads as its own hill instead of merging into one central mound.
+ return Array.from({ length: OPPORTUNITY_COUNT }, (_, i) => {
+ const col = i % SEED_GRID_COLS;
+ const row = Math.floor(i / SEED_GRID_COLS);
+ const cellW = 8 / SEED_GRID_COLS;
+ const cellH = 7.5 / SEED_GRID_ROWS;
+ return {
+ x: -4 + cellW * (col + 0.2 + rand() * 0.6),
+ y: -3.75 + cellH * (row + 0.2 + rand() * 0.6),
+ weight: 0.6 + rand() * 0.9,
+ phase: rand() * Math.PI * 2,
+ };
+ });
+})();
+
+/**
+ * Model state at time `t` (seconds). All motion is slow and periodic, and
+ * only touches terms of the equation that really change over a day:
+ * opportunity counts O_j, transit service (boarding waits, hence T) and
+ * beta/tau. Opportunities stay put, so hills rise and fall in place.
+ */
+export function getModelState(
+ t: number,
+ params: ModelParams = DEFAULT_PARAMS,
+): ModelState {
+ const opportunities = OPPORTUNITY_SEEDS.map((seed) => ({
+ x: seed.x,
+ y: seed.y,
+ weight: seed.weight * (1 + 0.35 * Math.sin(t * 0.21 + seed.phase)),
+ }));
+ const waits = TRANSIT_LINES.map((_, line) => boardingWait(line, t, params));
+
+ const stationCount = STATIONS.length;
+ const egress = new Float32Array(OPPORTUNITY_COUNT * stationCount);
+ if (!params.transitEnabled) egress.fill(NO_TRANSIT_COST);
+ opportunities.forEach((opp, j) => {
+ if (!params.transitEnabled) return;
+ const walkFrom = STATIONS.map(
+ (s) => Math.hypot(s.x - opp.x, s.y - opp.y) / params.walkSpeed,
+ );
+ STATIONS.forEach((board, k) => {
+ let best = Infinity;
+ STATIONS.forEach((alight, m) => {
+ if (m === k) return;
+ best = Math.min(
+ best,
+ rideTime(board, alight, params, waits[board.line]) + walkFrom[m],
+ );
+ });
+ egress[j * stationCount + k] = best;
+ });
+ });
+
+ return {
+ opportunities,
+ egress,
+ beta: params.autoBeta ? autoBeta(t) : params.beta,
+ tau: params.autoTau ? autoTau(t) : params.tau,
+ walkSpeed: params.walkSpeed,
+ };
+}
+
+/**
+ * Shape of the travel-time field. `core` rounds walking distance near zero
+ * (sqrt(d^2 + c^2) - c) and `smoothing` blends the walk/transit options with a
+ * smooth minimum, so peaks become rounded hills instead of cone tips. Both
+ * are 0 for the exact model. Mirrored in the vertex shader.
+ */
+export interface TravelShape {
+ core: number;
+ smoothing: number;
+}
+
+export const EXACT_TRAVEL: TravelShape = { core: 0, smoothing: 0 };
+
+export const TRAVEL_SHAPE: Record