feat: route log odometer and lit mile markers for profile and experience
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
src/composables/useRouteLog.ts
Normal file
62
src/composables/useRouteLog.ts
Normal 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') ?? '';
|
||||
});
|
||||
}
|
||||
@@ -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
145
src/journey/route-log.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
317
src/scene/world/milestones.ts
Normal file
317
src/scene/world/milestones.ts
Normal 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
27
src/styles/route-log.css
Normal 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; } }
|
||||
Reference in New Issue
Block a user