feat: route log odometer and lit mile markers for profile and experience

This commit is contained in:
2026-10-11 06:15:36 +03:00
parent b6e22a1299
commit 19f3ce79e6
29 changed files with 932 additions and 26 deletions

View File

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

View File

@@ -22,6 +22,7 @@ import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { useRouteLog } from '@/composables/useRouteLog';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
@@ -86,6 +87,8 @@ const journeyBinding = useJourneySections(
const journey = journeyBinding.snapshot;
/* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */
const maintenanceSubline = useMaintenanceWindow(journey);
/* MILE MARKERS (Experience, Profile): the ROUTE LOG odometer on the same sub-line. */
const routeLogSubline = useRouteLog(journey, () => props.content);
useCardEntrances();
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
const resume = computed(() => {
@@ -133,8 +136,9 @@ const controlsReady = shallowRef(false);
/* END OF SHIFT: parked at the destination the plate's sub-line reads the
shift's end. Client-only, like the 03:12 window, so the server HTML hydrates
unchanged. */
const hudSubline = computed(() => maintenanceSubline.value
|| (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : ''));
const hudSubline = computed(() => (controlsReady.value && journey.value.arrived ? END_OF_SHIFT_SUBLINE : '')
|| maintenanceSubline.value
|| routeLogSubline.value);
const adaptive = useAdaptiveQuality({
maximum: computed(() => motion.policy.value.maximumTier),
sceneState,

View File

@@ -22,6 +22,11 @@ const next = computed(() =>
a route rule that fills along its lower edge. */
const route = computed(() => Math.min(1, Math.max(0, props.journey.routeProgress)));
const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
/* MILE MARKERS: "LOG 02/04 · ..." rolls its index on 1-digit odometer
columns, each keyed by its value so a change re-enters (route-log.css).
Client-only: the server sub-line is empty, so its HTML is unchanged. */
const log = computed(() => /^LOG (\d+)\/(\d+)(.*)$/.exec(props.subline ?? ''));
const rewind = computed(() => (props.subline ?? '').endsWith('REWIND'));
/* The plate is keyed by chapter, so a new chapter is a new element: under
reduced motion it fades in over 160 ms (gantry.css) instead of swapping. */
</script>
@@ -43,6 +48,10 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
<span class="journey-hud__section">{{ sectionLabel }}</span>
<!-- END OF SHIFT: html[data-dawn='day'] swaps the label for data-day (CSS). -->
<strong v-if="journey.arrived" class="journey-hud__arrival" data-day="SHIFT OVER">ARRIVED</strong>
<span class="journey-hud__subline">{{ subline }}</span>
<template v-if="log">
<span class="journey-hud__subline" :data-rewind="rewind">LOG <span class="route-odo"><span v-for="(digit, i) in log[1]" :key="`${i}:${digit}`" class="route-odo__digit">{{ digit }}</span></span>/{{ log[2] }}{{ log[3] }}</span>
<span :key="log[1]" class="journey-hud__log-pulse"></span>
</template>
<span v-else class="journey-hud__subline">{{ subline }}</span>
</aside>
</template>

View File

@@ -0,0 +1,62 @@
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
import type { PortfolioContent } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types';
import {
createRewindLatch,
isMilestoneSection,
milestoneIndex,
milestonesFor,
routeLogSubline,
sectionLocalProgress,
} from '@/journey/route-log';
/**
* MILE MARKERS, DOM side: the ROUTE LOG sub-line and html[data-route-log]
* ("2/4") through Experience and Profile. Client-only and started after
* mount, like the 03:12 window, so the server HTML (no attribute, an empty
* sub-line) hydrates unchanged. Works without the scene.
*/
export function useRouteLog(
journey: Readonly<Ref<JourneySnapshot>>,
content: () => Pick<PortfolioContent, 'experience' | 'systemsOperations' | 'profile'>,
): Readonly<Ref<string>> {
const mounted = shallowRef(false);
const wide = shallowRef(true);
const reversing = shallowRef(false);
const latch = createRewindLatch();
const section = computed(() => {
const id = journey.value.activeSectionId;
return mounted.value && isMilestoneSection(id) ? id : null;
});
const items = computed(() => (section.value === null ? [] : milestonesFor(content(), section.value)));
const progress = computed(() =>
section.value === null || items.value.length < 2 ? null : sectionLocalProgress(journey.value, section.value));
const log = computed(() =>
progress.value === null ? null : `${milestoneIndex(progress.value, items.value.length) + 1}/${items.value.length}`);
watch(progress, (p) => { reversing.value = latch(p, section.value ?? undefined); }, { flush: 'sync' });
watch(log, (value) => {
const root = document.documentElement;
if (value === null) delete root.dataset.routeLog;
else root.dataset.routeLog = value;
});
let query: MediaQueryList | null = null;
const onWidth = (): void => { wide.value = query?.matches ?? true; };
onMounted(() => {
query = typeof matchMedia === 'function' ? matchMedia('(min-width: 1280px)') : null;
onWidth();
query?.addEventListener?.('change', onWidth);
mounted.value = true;
});
onUnmounted(() => {
mounted.value = false;
query?.removeEventListener?.('change', onWidth);
delete document.documentElement.dataset.routeLog;
});
return computed(() => {
const p = progress.value;
return p === null ? '' : routeLogSubline(p, items.value, reversing.value, wide.value ? 'wide' : 'compact') ?? '';
});
}

View File

@@ -1,4 +1,5 @@
import type { JourneySnapshot } from './types';
import { createRewindLatch, sectionLocalProgress } from './route-log';
/**
* The 03:12 window: the Infrastructure chapter's set-piece is driven by how
@@ -27,12 +28,7 @@ type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivatio
* line, 1 when the next chapter's heading reaches it. Null outside the chapter.
*/
export function maintenanceProgress(snapshot: ProgressInput): number | null {
if (snapshot.activeSectionId !== 'infrastructure') return null;
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
const span = next - active;
if (!(span > 0)) return null;
return Math.min(1, Math.max(0, -active / span));
return sectionLocalProgress(snapshot, 'infrastructure');
}
export function maintenanceStep(p: number): MaintenanceStep {
@@ -69,20 +65,4 @@ export function maintenanceSubline(p: number, reversing: boolean, live: () => st
* Latches the scroll direction inside the window: progress falling means a
* rollback until it rises again. Small jitter is ignored.
*/
export function createMaintenanceDirection(): (p: number | null) => boolean {
let last: number | null = null;
let reversing = false;
return (p) => {
if (p === null) {
last = null;
reversing = false;
return false;
}
if (last !== null) {
if (p < last - 0.001) reversing = true;
else if (p > last + 0.001) reversing = false;
}
last = p;
return reversing && p > 0 && p < 1;
};
}
export const createMaintenanceDirection: () => (p: number | null) => boolean = createRewindLatch;

145
src/journey/route-log.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { PortfolioContent, SectionId } from '@/content/types';
import type { JourneySnapshot } from './types';
/**
* MILE MARKERS: Experience and Profile become literal route. Each role or
* fact is a kilometre post that rolls past as its plate crosses the
* activation line, and the gantry sub-line keeps a ROUTE LOG odometer.
* Shared by the DOM (sub-line, html[data-route-log]) and the lazy scene
* chunk (post positions), so it stays tiny and pure. Scroll-driven only.
*/
type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
/**
* Section-local progress: 0 with the chapter heading on the activation line,
* 1 when the next chapter's heading reaches it. Null outside the chapter.
*/
export function sectionLocalProgress(snapshot: ProgressInput, id: SectionId): number | null {
if (snapshot.activeSectionId !== id) return null;
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
const span = next - active;
if (!(span > 0)) return null;
return Math.min(1, Math.max(0, -active / span));
}
/** Posts per chapter, in reading order (Experience: 3 roles + the systems plate). */
export const MILESTONE_COUNTS = { experience: 4, profile: 3 } as const;
export type MilestoneSection = keyof typeof MILESTONE_COUNTS;
export const isMilestoneSection = (id: SectionId): id is MilestoneSection => id in MILESTONE_COUNTS;
export interface Milestone {
index: number;
/** Wide plate, e.g. "JUN 2025 → AUG 2025"; '' when the content has no date. */
dateLabel: string;
/** Narrow plate, e.g. "JUN→AUG 2025". */
compactDate: string;
/** Organisation or fact, upper case, e.g. "ISENSE SOLUTIONS". */
caption: string;
}
/** Post i passes the line at the middle of its share of the chapter. */
export const passPoint = (index: number, count: number): number => (index + 0.5) / count;
const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ');
const month = (word: string | undefined): string =>
word !== undefined && MONTHS.includes(word.slice(0, 3).toLowerCase()) ? `${word.slice(0, 3).toUpperCase()} ` : '';
const SPAN = /(?:([A-Za-z]+)\s+)?(\d{4})\s*[-–]\s*(?:(?:([A-Za-z]+)\s+)?(\d{4})|(Present))/;
/** "June 2025-August 2025" -> JUN 2025 → AUG 2025; "2023-2026"; lists of years. */
export function parseDates(text: string): { dateLabel: string; compactDate: string } {
const span = SPAN.exec(text);
if (span !== null) {
const [, m1, y1, m2, y2, present] = span;
const end = present === undefined ? `${month(m2)}${y2}` : 'PRESENT';
const sameYear = y1 === y2 && month(m1) !== '' && month(m2) !== '';
return {
dateLabel: `${month(m1)}${y1} → ${end}`,
compactDate: sameYear ? `${month(m1).trim()}→${month(m2)}${y2}` : `${y1}→${present === undefined ? y2 : 'PRESENT'}`,
};
}
const years = [...new Set(text.match(/\b(?:19|20)\d{2}\b/g) ?? [])].sort().join(' · ');
return { dateLabel: years, compactDate: years };
}
type MilestoneContent = Pick<PortfolioContent, 'experience' | 'systemsOperations' | 'profile'>;
export function milestonesFor(content: MilestoneContent, section: SectionId): Milestone[] {
const rows: Array<{ dates: string; caption: string }> = section === 'experience'
? [
...content.experience.map((record) => ({ dates: record.dates, caption: record.organization })),
{ dates: '', caption: content.systemsOperations.title.replace(/^professional\s+/i, '') },
]
: section === 'profile'
? content.profile.screeningFacts.map((fact) => ({ dates: fact.value, caption: fact.label }))
: [];
return rows.map((row, index) => ({ index, ...parseDates(row.dates), caption: row.caption.toUpperCase() }));
}
/** Which post the reader is at: the one whose share of the chapter holds p. */
export const milestoneIndex = (p: number, count: number): number =>
Math.min(count - 1, Math.max(0, Math.floor(p * count)));
/** Sub-line widths in characters: the wide (>=1280 px) and narrow plates. */
export const ROUTE_LOG_BUDGET = { wide: 50, compact: 32 } as const;
/** Full name, then (3+ words) initials, then the first word, then initials, then nothing. */
export function fitCaption(caption: string, room: number): string {
const words = caption.split(/\s+/).filter(Boolean);
const initials = words.length > 1 ? words.map((word) => word[0]).join('') : '';
const tries = words.length > 2 ? [caption, initials, words[0] ?? ''] : [caption, words[0] ?? '', initials];
return tries.find((value) => value !== '' && value.length <= room) ?? '';
}
const pad = (value: number): string => String(value).padStart(2, '0');
/**
* "LOG 02/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS", with " · REWIND" while
* scrolling back. Null for chapters with fewer than two posts.
*/
export function routeLogSubline(
p: number,
items: readonly Milestone[],
reversing = false,
width: keyof typeof ROUTE_LOG_BUDGET = 'wide',
): string | null {
if (items.length < 2) return null;
const item = items[milestoneIndex(p, items.length)]!;
const date = width === 'wide' ? item.dateLabel : item.compactDate;
const index = `LOG ${pad(item.index + 1)}/${pad(items.length)}`;
const tail = reversing ? ' · REWIND' : '';
// The date goes before the caption, unless (narrow plate) it would leave the
// caption with less than its initials: then the caption alone is the better log line.
let head = date === '' ? index : `${index} · ${date}`;
let caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3);
if (date !== '' && caption.length < 3) {
head = index;
caption = fitCaption(item.caption, ROUTE_LOG_BUDGET[width] - head.length - tail.length - 3);
}
return `${head}${caption === '' ? '' : ` · ${caption}`}${tail}`;
}
/**
* Latches the scroll direction: progress falling means a rewind until it
* rises again. Small jitter is ignored; a new key (chapter) starts fresh.
*/
export function createRewindLatch(): (p: number | null, key?: string) => boolean {
let last: number | null = null;
let lastKey: string | undefined;
let reversing = false;
return (p, key) => {
if (p === null || key !== lastKey) {
last = null;
reversing = false;
lastKey = key;
if (p === null) return false;
}
if (last !== null) {
if (p < last - 0.001) reversing = true;
else if (p > last + 0.001) reversing = false;
}
last = p;
return reversing && p > 0 && p < 1;
};
}

View File

@@ -36,6 +36,8 @@ import {
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 { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -169,8 +171,13 @@ export async function createProceduralSceneGeneration(
// the star drain (uniform writes); the warm rims and the bloom change are
// high/medium fine-pointer only.
const dawnRims = maintenanceTier === 'full';
// 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');
scene.add(
maintenance.group,
milestones.mesh,
milestones.pool,
atmosphere.group,
road.group,
expressway.group,
@@ -351,6 +358,14 @@ export async function createProceduralSceneGeneration(
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
milestones.update(
snapshot.activeSectionId,
isMilestoneSection(snapshot.activeSectionId)
? sectionLocalProgress(snapshot, snapshot.activeSectionId)
: null,
musicPulse,
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);

View File

@@ -0,0 +1,317 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { isMilestoneSection, MILESTONE_COUNTS, passPoint, type MilestoneSection } from '@/journey/route-log';
import type { SceneResourceBag } from '@/scene/resources';
import { BEAM_PARS, beamUniforms } from './headlights';
/**
* MILE MARKERS, scene side. Each role (Experience) or screening fact
* (Profile) is a lit kilometre post on the shoulder of the side the reading
* panel leaves free. Its position is a pure function of section-local scroll
* progress, so scrolling back rolls the posts back. One InstancedMesh, one
* draw call: a dark post, a pale retro-reflective cap plate that catches the
* high beam, and a chapter-accent emissive stripe that breathes +8% with the
* bass. A post flashes for 300 ms as its plate crosses the activation line,
* which is the moment it reaches the camera: the post is at its largest, at
* the bottom edge of the frame, and the odometer ticks as it goes by. A second
* additive mesh lays a warm light pool on the shoulder under each post.
* Low tier and touch draw the posts without beam, pulse or flash.
*/
/**
* Where a post stands as its plate crosses the line. The camera sits at z ~25
* with the frame shifted toward the panel, so the free shoulder leaves the
* frame's side edge about 35 m ahead of the camera: the post goes by there,
* at its largest, as the odometer ticks.
*/
export const MILESTONE_PASS_Z = -12;
/** Metres of road per unit of travel: the next post is RUN / count metres up the road. */
export const MILESTONE_RUN = 200;
export const MILESTONE_FAR_Z = MILESTONE_PASS_Z - MILESTONE_RUN / 2;
export const MILESTONE_NEAR_Z = MILESTONE_PASS_Z + MILESTONE_RUN / 2;
/** The free shoulder: the Experience panel is on the left, Profile's on the right. */
export const MILESTONE_SIDE: Readonly<Record<MilestoneSection, number>> = { experience: 9.5, profile: -9.5 };
/* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */
const POST_W = 0.4;
const POST_H = 6.4;
const PLATE_W = 2.2;
const PLATE_H = 1.0;
/** The pool of light each post throws on the shoulder (x across the road, z along it). */
const POOL_W = 8;
const POOL_L = 14;
/** Plates turn this far toward the road (rad). */
const PLATE_YAW = 0.3;
const FLASH_SECONDS = 0.3;
/** One flash per post at most every 0.6 s, however fast the scroll (photosensitivity). */
const FLASH_GAP_SECONDS = 0.6;
const PULSE_GAIN = 0.08;
/** The cap plate's resting glow: it reads as lit retro-reflective sheeting at night. */
const PLATE_GLOW = 0.42;
const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[];
const INSTANCES = SECTIONS.reduce((sum, id) => sum + MILESTONE_COUNTS[id], 0);
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
return t * t * (3 - 2 * t);
};
/** Travel parameter of post i: 0.5 exactly when its plate is on the line. */
export const milestoneTravel = (p: number, index: number, count: number): number =>
p - passPoint(index, count) + 0.5;
export const milestoneZ = (p: number, index: number, count: number): number =>
THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, milestoneTravel(p, index, count));
/** The chapter fades the posts in and out at its edges. */
export const milestoneChapterFade = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
/** A post rises out of the haze far up the road and leaves once it is past the camera. */
export const milestoneRangeFade = (z: number): number => smooth(-190, -130, z) * (1 - smooth(10, 22, z));
export interface MilestonePost {
section: MilestoneSection;
index: number;
x: number;
z: number;
/** 0..1 screen-door cover: chapter edges and the far end of the run. */
presence: number;
}
/** Every post's place for this frame; posts of other chapters are absent (presence 0). */
export function milestoneLayout(section: SectionId, p: number | null): MilestonePost[] {
const posts: MilestonePost[] = [];
for (const id of SECTIONS) {
const count = MILESTONE_COUNTS[id];
for (let index = 0; index < count; index += 1) {
const t = p === null ? 0 : milestoneTravel(p, index, count);
const live = p !== null && id === section;
const z = THREE.MathUtils.lerp(MILESTONE_FAR_Z, MILESTONE_NEAR_Z, t);
posts.push({
section: id,
index,
x: MILESTONE_SIDE[id],
z,
presence: live ? milestoneChapterFade(p) * milestoneRangeFade(z) : 0,
});
}
}
return posts;
}
function box(width: number, height: number, depth: number, x: number, y: number, z: number, part: number) {
const geometry = new THREE.BoxGeometry(width, height, depth);
geometry.translate(x, y, z);
return { geometry, part };
}
/** Post, plate and stripes merged into one indexed geometry with a part id. */
function postGeometry() {
const pieces = [
box(POST_W, POST_H, POST_W, 0, POST_H / 2, 0, 0),
// The number plate, framed top and bottom by the chapter's accent.
box(PLATE_W, PLATE_H, 0.08, 0, POST_H + PLATE_H / 2 - 0.12, 0.18, 1),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H + PLATE_H - 0.06, 0.18, 2),
box(PLATE_W + 0.06, 0.12, 0.12, 0, POST_H - 0.18, 0.18, 2),
// Two reflector bands down the post, like a real kilometre post.
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.62, 0, 2),
box(POST_W + 0.05, 0.5, POST_W + 0.05, 0, POST_H * 0.3, 0, 2),
];
const positions: number[] = [];
const normals: 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(...position.array);
normals.push(...geometry.getAttribute('normal').array);
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(geometry.getIndex().array as ArrayLike<number>, (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('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
export interface MilestonesWorld {
readonly mesh: { visible: boolean; count: number };
/** The additive light pools on the shoulder, one per post. */
readonly pool: { visible: boolean; count: number };
/** Section-local progress (null outside the chapter) and the smoothed bass pulse (0..1). */
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number): void;
/** Test hook: the current flash level of each instance. */
flashes(): readonly number[];
}
export function createMilestones(resources: SceneResourceBag, full: boolean): MilestonesWorld {
const geometry = resources.own('geometry', postGeometry());
// x: flash 0..1, y: presence 0..1.
const state = new THREE.InstancedBufferAttribute(new Float32Array(INSTANCES * 2), 2);
state.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('aState', state);
const accent = new THREE.Color(SIGN_ACCENTS.experience);
const pulseUniform = { value: 0 };
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform, ...(full ? beamUniforms : {}) },
vertexShader: /* glsl */ `
attribute float aPart;
attribute vec2 aState;
varying float vPart;
varying vec2 vState;
varying vec3 vWorld;
varying vec3 vNormal;
void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
vPart = aPart;
vState = aState;
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
varying float vPart;
varying vec2 vState;
varying vec3 vWorld;
varying vec3 vNormal;
${full ? BEAM_PARS : ''}
void main() {
// Screen-door fade, 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 (vState.y < 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 facing = max(0.0, dot(n, toCamera));
float flash = ${full ? 'vState.x' : '0.0'};
vec3 color;
if (vPart < 0.5) {
color = vec3(0.04, 0.034, 0.06) + vec3(0.09, 0.08, 0.13) * key + uAccent * 0.06 * facing;
} else if (vPart < 1.5) {
// Lit retro-reflective plate: brightest facing the camera, and in the beam.
// Tinted by the chapter accent so it never reads as one more street lamp.
color = mix(vec3(0.86, 0.88, 0.96), uAccent, 0.7) * (${PLATE_GLOW.toFixed(2)} + 0.12 * key + 0.5 * facing * facing);
color += uAccent * 0.35;
${full ? 'color += vec3(1.0, 0.9, 0.76) * headlightBeam(vWorld) * (0.5 + 1.8 * facing);' : ''}
color += vec3(1.0, 0.96, 0.9) * flash * 1.4;
} else {
color = uAccent * (1.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse) + flash * 1.8);
}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
fog: false,
}));
const mesh = new THREE.InstancedMesh(geometry, material, INSTANCES);
mesh.name = 'mile-markers';
mesh.frustumCulled = false;
mesh.visible = false;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// The light pool: an additive quad on the shoulder, pulled toward the road.
const poolGeometry = resources.own('geometry', new THREE.PlaneGeometry(POOL_W, POOL_L).rotateX(-Math.PI / 2));
poolGeometry.setAttribute('aState', state);
const poolMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uAccent: { value: accent }, uPulse: pulseUniform },
vertexShader: /* glsl */ `
attribute vec2 aState;
varying vec2 vUv;
varying vec2 vState;
void main() {
vUv = uv;
vState = aState;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform vec3 uAccent;
uniform float uPulse;
varying vec2 vUv;
varying vec2 vState;
void main() {
vec2 d = (vUv - 0.5) * 2.0;
float r = length(d);
float pool = pow(max(0.0, 1.0 - r), 1.8);
vec3 warm = vec3(1.0, 0.86, 0.62);
vec3 color = mix(warm, uAccent, 0.5) * pool * (2.4 + ${PULSE_GAIN.toFixed(2)} * 3.0 * uPulse + 3.0 * vState.x);
gl_FragColor = vec4(color * vState.y, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
}));
const pool = new THREE.InstancedMesh(poolGeometry, poolMaterial, INSTANCES);
pool.name = 'mile-marker-pools';
pool.frustumCulled = false;
pool.visible = false;
pool.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const poolMatrix = new THREE.Matrix4();
const poolPosition = new THREE.Vector3();
const noRotation = new THREE.Quaternion();
const flash = new Float32Array(INSTANCES);
const sinceFlash = new Float32Array(INSTANCES).fill(Number.POSITIVE_INFINITY);
const side = new Float32Array(INSTANCES);
let lastSection: SectionId | null = null;
const matrix = new THREE.Matrix4();
const rotation = new THREE.Quaternion();
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
return {
mesh,
pool,
flashes: () => Array.from(flash),
update(section, p, pulse, deltaSeconds) {
const posts = milestoneLayout(section, p);
const changed = section !== lastSection;
lastSection = section;
let any = false;
posts.forEach((post, i) => {
// The flash fires when the plate crosses the line, either way.
const crossing = p === null || post.presence === 0 ? 0 : Math.sign(p - passPoint(post.index, MILESTONE_COUNTS[post.section]));
sinceFlash[i]! += deltaSeconds;
if (full && !changed && crossing !== 0 && side[i] !== 0 && crossing !== side[i] && sinceFlash[i]! >= FLASH_GAP_SECONDS) {
flash[i] = 1;
sinceFlash[i] = 0;
} else {
flash[i] = Math.max(0, flash[i]! - deltaSeconds / FLASH_SECONDS);
}
if (crossing !== 0) side[i] = crossing;
if (post.presence === 0) side[i] = 0;
const visible = post.presence > 0;
any ||= visible;
position.set(post.x, 0, post.z);
rotation.setFromAxisAngle(up, -Math.sign(post.x) * PLATE_YAW);
scale.setScalar(visible ? 1 : 0);
mesh.setMatrixAt(i, matrix.compose(position, rotation, scale));
poolPosition.set(post.x - Math.sign(post.x) * 1.4, 0.05, post.z);
pool.setMatrixAt(i, poolMatrix.compose(poolPosition, noRotation, scale));
state.setXY(i, flash[i]!, post.presence);
});
mesh.visible = any;
pool.visible = any;
if (!any) return;
if (isMilestoneSection(section)) accent.set(SIGN_ACCENTS[section]);
pulseUniform.value = full ? clamp01(pulse) : 0;
mesh.instanceMatrix.needsUpdate = true;
pool.instanceMatrix.needsUpdate = true;
state.needsUpdate = true;
},
};
}

27
src/styles/route-log.css Normal file
View File

@@ -0,0 +1,27 @@
/* ---------- MILE MARKERS: the ROUTE LOG odometer ----------
html[data-route-log='2/4'] is set client-side through Experience and
Profile. The log index rolls on 1-digit columns (each keyed by its value,
so a change re-enters) and the route rule thickens 1 px for 200 ms. */
html[data-route-log] .journey-hud__subline { letter-spacing: 0.06em; }
.route-odo { display: inline-flex; overflow: hidden; vertical-align: bottom; }
.route-odo__digit { display: inline-block; }
.journey-hud__log-pulse {
position: absolute; inset: auto auto 0 0; block-size: 3px;
inline-size: calc(var(--route, 0) * 100%);
background: var(--sign-accent); opacity: 0; pointer-events: none;
}
@media (min-width: 1280px) {
.journey-hud__log-pulse { inset: auto auto 12px 77px; inline-size: calc(var(--route, 0) * (100% - 107px)); }
}
@media (max-width: 767px) {
html[data-route-log] .journey-hud__subline { letter-spacing: 0.01em; }
}
@media (prefers-reduced-motion: no-preference) {
.route-odo__digit { animation: route-odo-roll 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
[data-rewind='true'] .route-odo__digit { animation-name: route-odo-rewind; }
.journey-hud__log-pulse { animation: route-log-rule 200ms ease-out both; }
}
@keyframes route-odo-roll { from { transform: translateY(100%); } }
@keyframes route-odo-rewind { from { transform: translateY(-100%); } }
@keyframes route-log-rule { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
@media (forced-colors: active), print { .journey-hud__log-pulse { display: none; } }