Merge branch 'polish/speedometer' into redesign/overnight

This commit is contained in:
2026-10-12 18:44:48 +03:00
33 changed files with 929 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -48,6 +48,8 @@ export const classifyArtifact = (path, source = '') => {
if (/^assets\/traffic-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
// PARKING LIGHTS: the favicon renderer, loaded after mount.
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
// SPEEDOMETER: the gantry dial's model and DOM driver, loaded after mount.
if (/^assets\/speedometer-dial-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';

View File

@@ -317,6 +317,10 @@ onUnmounted(() => removePageListeners());
:resume-id="resume?.id"
:allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick"
:frames="journeyBinding"
:navigation="navigation.state.value"
:sample-beam="headlight.sampleBeam"
:animated="motion.policy.value.reason === 'full'"
/>
</header>

View File

@@ -2,6 +2,10 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SectionId } from "@/content/types";
import type { JourneySnapshot } from "@/journey/types";
import type { NavigationState } from "@/journey/navigation-controller";
import type { BeamSample } from "@/journey/headlight-beam";
import type { JourneySectionsBinding } from "@/composables/useJourneySections";
import { useSpeedometer } from "@/composables/useSpeedometer";
import { SIGN_ACCENTS } from "@/journey/sign-routes";
const props = defineProps<{
@@ -13,6 +17,12 @@ const props = defineProps<{
/** A returning visitor's last exit: the window offers it from the start. */
resumeId?: SectionId | undefined;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
/** SPEEDOMETER (left bay): the journey's frame loop, the navigator and the beam. */
frames: Pick<JourneySectionsBinding, "requestFrame" | "subscribeFrame">;
navigation: NavigationState;
sampleBeam: (now: number) => BeamSample | null;
/** False snaps the needle: reduced motion, or motion paused. */
animated?: boolean;
}>();
const navigationLabels: Readonly<Record<SectionId, string>> = {
@@ -128,6 +138,31 @@ const driveOn = (): void => {
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
};
/* SPEEDOMETER: a 240-degree dial and a digital readout in the left bay,
driven by the drive (journey/speedometer.ts). Hover or focus names the
route, the exit and the time on the road; a click switches km/h and mph. */
const speedo = ref<HTMLElement | null>(null);
const speedometer = useSpeedometer({
root: speedo,
navigation: () => props.navigation,
frames: props.frames,
sampleBeam: (now) => props.sampleBeam(now),
animated: () => props.animated === true,
motionAllowed: () => props.allowMotion === true,
detail: () => {
const next = props.items[activeIndex.value + 1];
const route = `ROUTE ${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`;
const exit = `EXIT ${exitNumber(activeIndex.value)}/${exitNumber(props.items.length - 1)}`;
const ahead = next === undefined
? "END OF ROUTE"
: `NEXT ${exitNumber(activeIndex.value + 1)} ${navigationLabels[next.id]}`.toUpperCase();
return `${route} \u00b7 ${exit} \u00b7 ${ahead}`;
},
});
const speedoLabel = computed(() => (speedometer.units.value === "mph"
? "Speedometer in miles per hour. Switch to kilometres per hour"
: "Speedometer in kilometres per hour. Switch to miles per hour"));
const navigate = (event: MouseEvent): void => {
props.onSectionLinkClick(event, true);
if (window.matchMedia("(max-width: 767px)").matches)
@@ -137,6 +172,37 @@ const navigate = (event: MouseEvent): void => {
<template>
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
<button
ref="speedo"
type="button"
class="speedo"
:aria-label="speedoLabel"
data-engine="off"
@click="speedometer.toggleUnits"
@pointerenter="speedometer.setDetail(true)"
@pointerleave="speedometer.setDetail(false)"
@focus="speedometer.setDetail(true)"
@blur="speedometer.setDetail(false)"
>
<svg class="speedo__dial" viewBox="0 0 64 46" aria-hidden="true" focusable="false">
<path class="speedo__track" d="M11.2 42A24 24 0 1 1 52.8 42" pathLength="100" />
<path class="speedo__redline" d="M11.2 42A24 24 0 1 1 52.8 42" pathLength="100" />
<path class="speedo__glow" d="M11.2 42A24 24 0 1 1 52.8 42" pathLength="100" />
<path class="speedo__fill" d="M11.2 42A24 24 0 1 1 52.8 42" pathLength="100" />
<!-- Ticks every 20 km/h over the 240-degree sweep, major every 40. -->
<path class="speedo__ticks" d="M10.8 33.7L8.4 34.2M13.4 19.2L11.2 18.0M24.6 9.8L23.8 7.4M39.4 9.8L40.2 7.4M50.6 19.2L52.8 18.0M53.2 33.7L55.6 34.2" />
<path class="speedo__ticks" data-major d="M16.0 39.2L11.2 42.0M13.8 26.8L8.4 25.8M20.1 15.8L16.6 11.6M32.0 11.5L32.0 6.0M43.9 15.8L47.4 11.6M50.2 26.8L55.6 25.8M48.0 39.2L52.8 42.0" />
<g class="speedo__needle"><line x1="32" y1="33" x2="32" y2="9" /></g>
<circle class="speedo__hub" cx="32" cy="30" r="2.4" />
</svg>
<span class="speedo__readout" aria-hidden="true">
<span class="speedo__digits">000</span>
<span class="speedo__unit">{{ speedometer.units.value === 'mph' ? 'MPH' : 'KM/H' }}</span>
<span class="speedo__lamp"></span>
<span class="speedo__line">TRIP <span class="speedo__trip">000.0</span><span class="speedo__gear">P</span></span>
</span>
<span class="speedo__detail" aria-hidden="true" data-detail=""></span>
</button>
<details ref="disclosure" class="site-navigation__disclosure" open>
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
<ul ref="list">

View File

@@ -0,0 +1,90 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue';
import type { BeamSample } from '@/journey/headlight-beam';
import type { NavigationState } from '@/journey/navigation-controller';
import type { SpeedometerDial } from '@/journey/speedometer-dial';
import type { SpeedUnits } from '@/journey/speedometer';
import type { JourneySnapshot } from '@/journey/types';
const UNITS_KEY = 'nightdrive:units';
export interface SpeedometerSources {
/** The gauge button; its readouts are found by class, after mount. */
root: Ref<HTMLElement | null>;
navigation: () => NavigationState;
/** The journey's frame loop: the gauge rides it instead of its own rAF. */
frames: {
requestFrame(): void;
subscribeFrame(listener: (now: number, snapshot: JourneySnapshot) => boolean): () => void;
};
sampleBeam: (now: number) => BeamSample | null;
animated: () => boolean;
motionAllowed: () => boolean;
detail: () => string;
}
export interface SpeedometerBinding {
units: Ref<SpeedUnits>;
toggleUnits(): void;
setDetail(on: boolean): void;
}
/**
* SPEEDOMETER: the gauge in the gantry's left bay. The model and the DOM
* driver are a lazy chunk (journey/speedometer-dial.ts) fetched after mount;
* until it lands the server-rendered dial reads engine off, which is where
* the ignition starts anyway. This side keeps the units preference and
* hands the driver the journey's frame loop.
*/
export function useSpeedometer(sources: SpeedometerSources): SpeedometerBinding {
const units = ref<SpeedUnits>('kmh');
let dial: SpeedometerDial | null = null;
let detailOn = false;
let mounted = false;
let unsubscribe = (): void => {};
onMounted(() => {
const el = sources.root.value;
if (!el) return;
mounted = true;
try {
units.value = window.localStorage.getItem(UNITS_KEY) === 'mph' ? 'mph' : 'kmh';
} catch {
// No memory: km/h.
}
import('@/journey/speedometer-dial').then((module) => {
if (!mounted) return;
dial = module.createSpeedometerDial(el, {
...sources,
units: () => units.value,
kick: () => sources.frames.requestFrame(),
});
dial.setDetail(detailOn);
unsubscribe = sources.frames.subscribeFrame(dial.frame);
sources.frames.requestFrame();
}, () => {});
});
onUnmounted(() => {
mounted = false;
unsubscribe();
dial?.dispose();
dial = null;
});
return {
units,
toggleUnits() {
units.value = units.value === 'mph' ? 'kmh' : 'mph';
try {
window.localStorage.setItem(UNITS_KEY, units.value);
} catch {
// A preference, not a record.
}
sources.frames.requestFrame();
},
setDetail(on) {
detailOn = on;
dial?.setDetail(on);
},
};
}

View File

@@ -0,0 +1,150 @@
/**
* SPEEDOMETER, DOM side (a lazy chunk with the model: nothing of it is in
* the initial bundle). Each journey frame steps the pure model and writes
* only what changed: --needle/--speed/--throttle on the gauge, data-engine,
* data-brake, data-jump and data-flash, and the digit, gear, trip and detail
* text. The frame keeps the journey loop running while the needle settles,
* during a flash and for the ignition sweep; otherwise scroll events and the
* navigator drive it. Below 1280 the bay is the summary's and the gauge is
* display: none, so the frame does nothing there.
*/
import type { BeamSample } from '@/journey/headlight-beam';
import { readIgnition } from '@/journey/ignition-feed';
import type { NavigationState } from '@/journey/navigation-controller';
import {
createBeamFlashDetector,
createSpeedometerModel,
formatSpeed,
formatTrip,
needleDegrees,
MAX_KMH,
type SpeedometerInput,
type SpeedUnits,
} from '@/journey/speedometer';
import type { JourneySnapshot } from '@/journey/types';
/** With a scene on its way the engine waits for the ignition this long at most. */
const IGNITION_WAIT_MS = 4_000;
/** After a click the loop runs long enough to see the high beam lift. */
const FLASH_WATCH_MS = 600;
export interface DialSources {
navigation: () => NavigationState;
/** HIGH BEAM: the gauge flares with the flash. */
sampleBeam: (now: number) => BeamSample | null;
/** False snaps the needle (reduced motion, paused). */
animated: () => boolean;
/** A scene is coming: the engine waits for its ignition. */
motionAllowed: () => boolean;
units: () => SpeedUnits;
/** The hover line (route, exits, next), minus the time on the road. */
detail: () => string;
/** Asks the journey loop for a frame. */
kick: () => void;
}
export interface SpeedometerDial {
frame(now: number, journey: JourneySnapshot): boolean;
/** Pointer or focus on the gauge: the detail line follows the drive. */
setDetail(on: boolean): void;
dispose(): void;
}
export function createSpeedometerDial(el: HTMLElement, sources: DialSources): SpeedometerDial {
const model = createSpeedometerModel();
const input: SpeedometerInput = {
driveSpeed: 0, direction: 0, acceleration: 0, routeProgress: 0, ignition: 0, jump: false, arrived: false,
};
const mountedAt = performance.now();
const query = window.matchMedia('(min-width: 1280px)');
let wide = query.matches;
let watchUntil = -Infinity;
const flashOf = createBeamFlashDetector();
let detailOn = false;
// Last written values, so a frame that changes nothing writes nothing.
const numbers = [NaN, NaN, NaN];
const strings = ['', '', '', '', ''];
const flags = [false, false, false];
const prop = (slot: number, name: string, value: number, epsilon: number, digits: number): void => {
if (Math.abs(value - numbers[slot]!) < epsilon) return;
numbers[slot] = value;
el.style.setProperty(name, value.toFixed(digits));
};
const text = (slot: number, selector: string, value: string, attribute?: string): void => {
if (strings[slot] === value) return;
strings[slot] = value;
const target = el.querySelector(selector);
if (!target) return;
if (attribute) target.setAttribute(attribute, value);
else target.textContent = value;
};
const flag = (slot: number, name: string, on: boolean): void => {
if (flags[slot] === on) return;
flags[slot] = on;
el.toggleAttribute(name, on);
};
// The scene writes html[data-ignition] from its first frame; until then
// (or for 4 s at most) the engine is off. Landing mid-page skips it.
let awaitingIgnition = sources.motionAllowed() && window.location.hash === '';
const ignited = (): void => {
awaitingIgnition = false;
window.clearTimeout(ignitionTimer);
sources.kick();
};
const ignitionTimer = awaitingIgnition ? window.setTimeout(ignited, IGNITION_WAIT_MS) : 0;
const observer = new MutationObserver(ignited);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-ignition'] });
const onQuery = (): void => { wide = query.matches; sources.kick(); };
query.addEventListener('change', onQuery);
// A click anywhere may flash the high beam: watch the beam for a moment.
const onClick = (): void => { watchUntil = performance.now() + FLASH_WATCH_MS; sources.kick(); };
document.addEventListener('click', onClick, { passive: true });
return {
frame(now, journey) {
if (!wide) return false;
const navigation = sources.navigation();
input.driveSpeed = journey.driveSpeed;
input.direction = journey.direction;
input.acceleration = journey.acceleration;
input.routeProgress = journey.routeProgress;
input.ignition = awaitingIgnition ? 0 : readIgnition().phase;
input.jump = navigation.phase === 'travelling' && navigation.source === 'explicit-navigation';
input.arrived = journey.arrived;
const out = model.step(now, input, sources.animated());
const beam = sources.sampleBeam(now);
const flashing = flashOf(beam === null ? 0 : beam.intensity) && out.engine === 'on';
const units = sources.units();
prop(0, '--needle', needleDegrees(out.kmh), 0.15, 1);
prop(1, '--speed', Math.min(1, out.kmh / MAX_KMH), 0.002, 3);
prop(2, '--throttle', out.throttle, 0.01, 2);
text(0, '.speedo__digits', formatSpeed(out.kmh, units));
text(1, '.speedo__gear', out.gear);
text(2, '.speedo__trip', formatTrip(out.tripKm, units));
const engine = out.engine === 'off' && input.arrived ? 'parked' : out.engine;
if (strings[3] !== engine) { strings[3] = engine; el.dataset.engine = engine; }
flag(0, 'data-brake', out.brake);
flag(1, 'data-jump', input.jump);
flag(2, 'data-flash', flashing);
if (detailOn) {
const minutes = Math.max(1, Math.round((now - mountedAt) / 60_000));
text(4, '.speedo__detail', `${sources.detail()} · ${minutes} MIN ON THE ROAD`, 'data-detail');
}
return !out.settled || flashing || now < watchUntil;
},
setDetail(on) {
detailOn = on;
if (on) sources.kick();
},
dispose() {
observer.disconnect();
query.removeEventListener('change', onQuery);
document.removeEventListener('click', onClick);
window.clearTimeout(ignitionTimer);
},
};
}

178
src/journey/speedometer.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* SPEEDOMETER: the instrument in the gantry's left bay. Pure: the drive
* state (driveSpeed, direction, acceleration, route progress), the scene's
* ignition phase and the navigator's state map to a target speed; a light
* underdamped spring carries the needle there (a rev overshoot, a settle),
* and the readouts (digits, gear, trip) derive from the needle. Nothing here
* touches the DOM or allocates per step: the frame object is reused.
*/
export type SpeedUnits = 'kmh' | 'mph';
/** off: before ignition / parked. lamps: backlight on, needle at rest.
* on: driving. */
export type EngineState = 'off' | 'lamps' | 'on';
/** Idle cruise reads 88 km/h; the fastest scroll pins the needle at 240. */
export const CRUISE_KMH = 88;
export const MAX_KMH = 240;
/** Scrolling back is braking: full reverse throttle reads 34 km/h. */
export const BRAKE_KMH = 34;
/** The route end to end, for the trip odometer. */
export const ROUTE_KM = 48;
export const KMH_PER_MPH = 1.609344;
/** The idle drive speed of the scroll controller (scroll-drive-controller.ts). */
export const CRUISE_DRIVE = 0.18;
/** The needle sweeps 240 degrees, from 8 o'clock over the top to 4 o'clock. */
export const NEEDLE_SWEEP_DEG = 240;
/** Ignition self-test: the needle sweeps to full scale, then falls to cruise. */
export const SWEEP_MS = 720;
/** Gear thresholds (km/h) for the shift indicator. */
const GEARS: readonly number[] = [6, 30, 55, 85, 120, 170];
export interface SpeedometerInput {
driveSpeed: number;
direction: -1 | 0 | 1;
acceleration: number;
routeProgress: number;
/** The scene's ignition phase (ignition-feed.ts): 0 cold, 1 lamps, 2 rolling, 3 driving. */
ignition: 0 | 1 | 2 | 3;
/** An explicit navigation in flight: overdrive. */
jump: boolean;
/** Parked at the destination: engine off. */
arrived: boolean;
}
export interface SpeedometerFrame {
/** The needle, km/h. */
kmh: number;
target: number;
engine: EngineState;
brake: boolean;
/** 0..1, how hard the drive is pushing (lights the arc). */
throttle: number;
gear: string;
tripKm: number;
/** Nothing moves until the input changes. */
settled: boolean;
}
export interface SpeedometerModel {
/** Advance to `now` (ms). `animated` false snaps the needle to its target. */
step(now: number, input: SpeedometerInput, animated: boolean): SpeedometerFrame;
}
const clamp01 = (v: number): number => (v > 0 ? (v < 1 ? v : 1) : 0);
export const isBraking = (input: Pick<SpeedometerInput, 'direction' | 'acceleration'>): boolean =>
input.direction === -1 && input.acceleration > 0.04;
/** Where the needle wants to be, km/h. */
export function speedTargetKmh(input: SpeedometerInput): number {
if (input.arrived || input.ignition < 2) return 0;
if (input.jump) return MAX_KMH;
const push = clamp01((input.driveSpeed - CRUISE_DRIVE) / (1 - CRUISE_DRIVE));
if (isBraking(input)) return CRUISE_KMH - (CRUISE_KMH - BRAKE_KMH) * clamp01(input.acceleration);
return CRUISE_KMH + (MAX_KMH - CRUISE_KMH) * push;
}
export function engineStateFor(input: Pick<SpeedometerInput, 'ignition' | 'arrived'>): EngineState {
if (input.arrived) return 'off';
return input.ignition === 0 ? 'off' : input.ignition === 1 ? 'lamps' : 'on';
}
/** Needle rotation in degrees (0 at 12 o'clock, clockwise positive). */
export const needleDegrees = (kmh: number): number =>
-NEEDLE_SWEEP_DEG / 2 + NEEDLE_SWEEP_DEG * clamp01(kmh / MAX_KMH);
export function gearFor(kmh: number, engine: EngineState): string {
if (engine !== 'on') return 'P';
let gear = 0;
for (const threshold of GEARS) if (kmh >= threshold) gear += 1;
return gear === 0 ? 'N' : String(gear);
}
export const tripKmFor = (routeProgress: number): number => ROUTE_KM * clamp01(routeProgress);
export const toUnits = (kmh: number, units: SpeedUnits): number =>
units === 'mph' ? kmh / KMH_PER_MPH : kmh;
/** Three digits for the readout: "088". A rev overshoot stays on the dial. */
export const formatSpeed = (kmh: number, units: SpeedUnits): string =>
String(Math.round(toUnits(Math.min(MAX_KMH, Math.max(0, kmh)), units))).padStart(3, '0');
/** "012.4" for the trip odometer. */
export const formatTrip = (km: number, units: SpeedUnits): string =>
Math.max(0, toUnits(km, units)).toFixed(1).padStart(5, '0');
export const unitLabel = (units: SpeedUnits): string => (units === 'mph' ? 'MPH' : 'KM/H');
/** Spring: omega 7.5 rad/s (a 0.84 s period on the overshoot), zeta 0.62. */
const OMEGA = 7.5;
const ZETA = 0.62;
const MAX_STEP_S = 0.05;
const SETTLE_KMH = 0.25;
const SETTLE_RATE = 2;
export function createSpeedometerModel(): SpeedometerModel {
let kmh = 0;
let rate = 0;
let last: number | null = null;
let sweepUntil = -Infinity;
let previousEngine: EngineState = 'off';
const frame: SpeedometerFrame = {
kmh: 0, target: 0, engine: 'off', brake: false, throttle: 0, gear: 'P', tripKm: 0, settled: false,
};
return {
step(now, input, animated) {
const engine = engineStateFor(input);
// The engine coming on runs the self-test sweep (not under reduced motion).
if (engine === 'on' && previousEngine !== 'on' && animated) sweepUntil = now + SWEEP_MS;
previousEngine = engine;
const target = now < sweepUntil ? MAX_KMH : speedTargetKmh(input);
const dt = last === null ? 0 : Math.min(MAX_STEP_S, Math.max(0, (now - last) / 1000));
last = now;
if (!animated) {
kmh = target;
rate = 0;
} else if (dt > 0) {
// Semi-implicit Euler on x'' = -w^2 (x - t) - 2 z w x'.
rate += (-OMEGA * OMEGA * (kmh - target) - 2 * ZETA * OMEGA * rate) * dt;
kmh += rate * dt;
if (kmh < 0) { kmh = 0; rate = 0; }
if (Math.abs(kmh - target) < SETTLE_KMH && Math.abs(rate) < SETTLE_RATE) {
kmh = target;
rate = 0;
}
}
frame.kmh = kmh;
frame.target = target;
frame.engine = engine;
frame.brake = engine === 'on' && !input.jump && isBraking(input);
frame.throttle = engine === 'on' ? (input.jump ? 1 : clamp01(input.acceleration)) : 0;
frame.gear = gearFor(kmh, engine);
frame.tripKm = tripKmFor(input.routeProgress);
frame.settled = kmh === target && rate === 0 && now >= sweepUntil;
return frame;
},
};
}
/**
* HIGH BEAM: a flash is a fast lift of the beam to 1 from its 0.72 rest. The
* detector arms on a steady or falling reading at rest level (0.3 to 0.85),
* so neither the dark beam before the scene nor the intro sweep (0 to 1,
* rising) reads as a flash, and it holds while the beam stays above 0.95.
*/
export function createBeamFlashDetector(): (intensity: number) => boolean {
let armed = false;
let last = 0;
let on = false;
return (intensity) => {
if (intensity > 0.3 && intensity < 0.85 && intensity <= last + 1e-3) armed = true;
last = intensity;
if (on) on = intensity > 0.95;
else if (armed && intensity > 0.95) { on = true; armed = false; }
return on;
};
}

View File

@@ -98,8 +98,227 @@ html .site-navigation[data-enhanced='true'][data-docked='true']::before {
mask-composite: exclude, add;
}
/* No brand plate in the left bay: the hero carries the name (owner's call:
a name in the nav bar read as self-promotion). */
/* ---------- SPEEDOMETER in the left bay ----------
No brand plate here (owner's call: a name in the nav bar read as
self-promotion). Instead the dash's instrument: a 240-degree dial whose
arc fills in the chapter's sign colour, a needle on a light spring
(useSpeedometer writes --needle, --speed, --throttle and the data-* states
per journey frame), a three-digit readout, the trip odometer and a gear.
Wide only: below 1280 the bay is the summary's, and the dial is gone. */
.speedo {
--dial-ink: #bfaed3;
--dial-lit: #f4e6ff;
position: absolute;
z-index: 1;
inset: 0 auto 0 16px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
column-gap: 8px;
inline-size: calc(var(--bay-l) - 28px);
margin: 0;
padding: 0 4px;
border: 0;
border-radius: 0;
background: none;
color: var(--dial-ink);
font: 650 0.6rem/1 var(--mono);
letter-spacing: 0.1em;
text-align: start;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.speedo:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -6px;
}
.speedo__dial {
display: block;
inline-size: 64px;
block-size: 46px;
overflow: visible;
fill: none;
stroke-linecap: round;
}
.speedo__track {
stroke: rgb(217 200 244 / 16%);
stroke-width: 3;
}
/* The top fifth of the scale (192-240) is the redline. */
.speedo__redline {
stroke: rgb(255 93 122 / 55%);
stroke-width: 3;
stroke-dasharray: 20 100;
stroke-dashoffset: -80;
}
.speedo__fill,
.speedo__glow {
stroke: var(--sign-accent, #ff83c2);
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: calc(100 - var(--speed, 0) * 100);
transition: stroke 480ms ease;
}
.speedo__glow {
stroke-width: 7;
opacity: calc(0.25 + var(--throttle, 0) * 0.6);
filter: blur(3px);
}
.speedo__ticks {
stroke: rgb(217 200 244 / 32%);
stroke-width: 1;
}
.speedo__ticks[data-major] {
stroke: rgb(217 200 244 / 55%);
stroke-width: 1.4;
}
.speedo__needle {
stroke: var(--focus);
stroke-width: 2;
transform-box: view-box;
transform-origin: 32px 30px;
transform: rotate(calc(var(--needle, -120) * 1deg));
filter: drop-shadow(0 0 2px rgb(255 214 147 / 70%));
}
.speedo__hub {
fill: #170e29;
stroke: var(--focus);
stroke-width: 1.2;
}
.speedo__readout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
align-items: baseline;
column-gap: 6px;
row-gap: 5px;
min-inline-size: 0;
}
.speedo__digits {
color: var(--dial-lit);
font-size: 1.3rem;
font-weight: 560;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
text-shadow: 0 0 10px color-mix(in srgb, var(--sign-accent, #ff83c2) calc(var(--throttle, 0) * 90%), transparent);
}
.speedo__unit,
.speedo__lamp {
grid-area: 1 / 2;
font-size: 0.5rem;
letter-spacing: 0.14em;
white-space: nowrap;
transition: opacity 120ms ease;
}
.speedo__unit { opacity: 0.8; }
/* The warning lamp takes the unit's place: BRAKE on a reverse scroll,
OVERDRIVE on a navigator jump, ENGINE OFF once parked. */
.speedo__lamp {
font-size: 0.46rem;
opacity: 0;
}
.speedo:is([data-brake], [data-jump], [data-engine='parked']) .speedo__unit { opacity: 0; transition-duration: 0ms; }
.speedo__lamp::before { content: '' / ''; }
.speedo[data-brake] .speedo__lamp,
.speedo[data-jump] .speedo__lamp,
.speedo[data-engine='parked'] .speedo__lamp { opacity: 1; }
.speedo[data-brake] .speedo__lamp { color: #ff8da4; }
.speedo[data-brake] .speedo__lamp::before { content: 'BRAKE' / ''; }
.speedo[data-jump] .speedo__lamp { color: var(--sign-accent, #ff83c2); }
.speedo[data-jump] .speedo__lamp::before { content: 'OVERDRIVE' / ''; }
.speedo[data-engine='parked'] .speedo__lamp { color: var(--focus); }
.speedo[data-engine='parked'] .speedo__lamp::before { content: 'ENGINE OFF' / ''; }
.speedo__line {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
gap: 5px;
font-size: 0.52rem;
letter-spacing: 0.12em;
white-space: nowrap;
}
.speedo__trip {
color: var(--dial-lit);
font-variant-numeric: tabular-nums;
}
.speedo__gear {
margin-inline-start: auto;
padding: 2px 4px 1px;
border: 1px solid rgb(217 200 244 / 30%);
border-radius: 2px;
color: var(--dial-lit);
font-size: 0.5rem;
line-height: 1;
}
/* Engine off (cold, or parked at the destination): a dark dial. */
.speedo[data-engine='off'] .speedo__dial,
.speedo[data-engine='parked'] .speedo__dial { opacity: 0.45; }
.speedo[data-engine='off'] .speedo__readout,
.speedo[data-engine='parked'] .speedo__readout { --dial-lit: #8f7aa6; }
.speedo[data-engine='parked'] .speedo__digits { color: #c9b8dc; }
/* Lamps: the backlight warms before the needle sweeps. */
.speedo[data-engine='lamps'] .speedo__dial { opacity: 0.7; }
.speedo[data-engine='lamps'] .speedo__digits { color: #e6c89c; }
.speedo__dial,
.speedo__readout { transition: opacity 480ms ease; }
/* HIGH BEAM: the flash lights the whole instrument for its 0.4 s. */
.speedo::after {
content: '' / '';
position: absolute;
inset: 8px -6px 8px -2px;
border-radius: 6px;
background: radial-gradient(60% 80% at 30% 50%, rgb(255 236 200 / 22%), transparent 70%);
opacity: 0;
pointer-events: none;
transition: opacity 360ms ease;
}
.speedo[data-flash]::after {
opacity: 1;
transition-duration: 90ms;
}
.speedo[data-jump] .speedo__fill { stroke: var(--focus); }
/* Hover or focus: the route line under the bay (decorative). */
.speedo__detail {
position: absolute;
inset: calc(100% + 12px) auto auto 4px;
translate: 0 -4px;
padding: 0.38rem 0.6rem;
border: 1px solid rgb(255 214 147 / 45%);
border-radius: 3px;
background: #170e29;
color: #ffe3b8;
font: 650 0.64rem/1 var(--mono);
letter-spacing: 0.14em;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease, translate 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.speedo__detail::before { content: attr(data-detail) / ''; }
.speedo:is(:hover, :focus-visible) .speedo__detail[data-detail]:not([data-detail='']) {
opacity: 1;
translate: 0 0;
}
@media (max-width: 1279px) {
.speedo { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.speedo__dial,
.speedo__readout,
.speedo__fill,
.speedo__glow,
.speedo__lamp,
.speedo::after,
.speedo__detail { transition: none; }
}
@media (forced-colors: active) {
.speedo { color: CanvasText; }
.speedo__track, .speedo__ticks { stroke: GrayText; }
.speedo__fill, .speedo__needle { stroke: Highlight; }
.speedo__glow { display: none; }
.speedo__detail { border-color: CanvasText; }
}
/* ---------- Exits on the beam ---------- */
@media (min-width: 768px) {

View File

@@ -73,6 +73,7 @@ describe('release budgets', () => {
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video');
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import {
BRAKE_KMH,
CRUISE_DRIVE,
CRUISE_KMH,
MAX_KMH,
ROUTE_KM,
SWEEP_MS,
createBeamFlashDetector,
createSpeedometerModel,
engineStateFor,
formatSpeed,
formatTrip,
gearFor,
needleDegrees,
speedTargetKmh,
tripKmFor,
type SpeedometerInput,
} from '@/journey/speedometer';
const driving = (overrides: Partial<SpeedometerInput> = {}): SpeedometerInput => ({
driveSpeed: CRUISE_DRIVE,
direction: 0,
acceleration: 0,
routeProgress: 0,
ignition: 3,
jump: false,
arrived: false,
...overrides,
});
describe('speedTargetKmh', () => {
it('reads the idle cruise at the controller cruise speed', () => {
expect(speedTargetKmh(driving())).toBe(CRUISE_KMH);
});
it('pins the needle at full scale for the fastest scroll', () => {
expect(speedTargetKmh(driving({ driveSpeed: 1, acceleration: 1, direction: 1 }))).toBe(MAX_KMH);
});
it('maps the drive speed linearly between cruise and full scale', () => {
const half = speedTargetKmh(driving({ driveSpeed: CRUISE_DRIVE + (1 - CRUISE_DRIVE) / 2, direction: 1, acceleration: 0.5 }));
expect(half).toBeCloseTo((CRUISE_KMH + MAX_KMH) / 2, 6);
});
it('brakes on a reverse scroll, down to the brake floor', () => {
expect(speedTargetKmh(driving({ driveSpeed: 1, direction: -1, acceleration: 1 }))).toBe(BRAKE_KMH);
expect(speedTargetKmh(driving({ driveSpeed: 0.5, direction: -1, acceleration: 0.5 }))).toBeCloseTo((CRUISE_KMH + BRAKE_KMH) / 2, 6);
});
it('ignores a reverse direction with no real push (a settling sample)', () => {
expect(speedTargetKmh(driving({ direction: -1, acceleration: 0.01 }))).toBe(CRUISE_KMH);
});
it('goes to overdrive on a navigator jump and to zero when parked or before ignition', () => {
expect(speedTargetKmh(driving({ jump: true }))).toBe(MAX_KMH);
expect(speedTargetKmh(driving({ arrived: true, driveSpeed: 1 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 0 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 1 }))).toBe(0);
expect(speedTargetKmh(driving({ ignition: 2 }))).toBe(CRUISE_KMH);
});
it('never leaves the dial', () => {
for (const driveSpeed of [-1, 0, 0.1, 2, Number.NaN]) {
const target = speedTargetKmh(driving({ driveSpeed, direction: 1, acceleration: 1 }));
expect(target).toBeGreaterThanOrEqual(0);
expect(target).toBeLessThanOrEqual(MAX_KMH);
}
});
});
describe('engineStateFor', () => {
it('follows the ignition phase, and parks at the destination', () => {
expect(engineStateFor({ ignition: 0, arrived: false })).toBe('off');
expect(engineStateFor({ ignition: 1, arrived: false })).toBe('lamps');
expect(engineStateFor({ ignition: 2, arrived: false })).toBe('on');
expect(engineStateFor({ ignition: 3, arrived: false })).toBe('on');
expect(engineStateFor({ ignition: 3, arrived: true })).toBe('off');
});
});
describe('readouts', () => {
it('rotates the needle over 240 degrees from 8 to 4 o\'clock', () => {
expect(needleDegrees(0)).toBe(-120);
expect(needleDegrees(MAX_KMH)).toBe(120);
expect(needleDegrees(MAX_KMH / 2)).toBeCloseTo(0, 9);
expect(needleDegrees(-50)).toBe(-120);
expect(needleDegrees(999)).toBe(120);
});
it('shifts up through the gears, and parks when the engine is off', () => {
expect(gearFor(0, 'on')).toBe('N');
expect(gearFor(10, 'on')).toBe('1');
expect(gearFor(CRUISE_KMH, 'on')).toBe('4');
expect(gearFor(MAX_KMH, 'on')).toBe('6');
expect(gearFor(CRUISE_KMH, 'off')).toBe('P');
expect(gearFor(CRUISE_KMH, 'lamps')).toBe('P');
});
it('formats three digits in either unit', () => {
expect(formatSpeed(CRUISE_KMH, 'kmh')).toBe('088');
expect(formatSpeed(CRUISE_KMH, 'mph')).toBe('055');
expect(formatSpeed(0, 'kmh')).toBe('000');
expect(formatSpeed(MAX_KMH, 'mph')).toBe('149');
expect(formatSpeed(5000, 'kmh')).toBe('240');
expect(formatSpeed(-5, 'kmh')).toBe('000');
});
it('runs the trip odometer along the route', () => {
expect(tripKmFor(0)).toBe(0);
expect(tripKmFor(0.5)).toBe(ROUTE_KM / 2);
expect(tripKmFor(1.7)).toBe(ROUTE_KM);
expect(formatTrip(12.44, 'kmh')).toBe('012.4');
expect(formatTrip(0, 'kmh')).toBe('000.0');
expect(formatTrip(ROUTE_KM, 'mph')).toBe('029.8');
});
});
describe('createSpeedometerModel', () => {
const run = (
model: ReturnType<typeof createSpeedometerModel>,
input: SpeedometerInput,
from: number,
to: number,
animated = true,
step = 16,
): number[] => {
const trace: number[] = [];
for (let now = from; now <= to; now += step) trace.push(model.step(now, input, animated).kmh);
return trace;
};
it('starts dark, runs the self-test sweep on ignition, then settles at cruise', () => {
const model = createSpeedometerModel();
expect(model.step(0, driving({ ignition: 0 }), true)).toMatchObject({ kmh: 0, engine: 'off', gear: 'P', settled: true });
expect(model.step(300, driving({ ignition: 1 }), true)).toMatchObject({ kmh: 0, engine: 'lamps', settled: true });
const sweep = run(model, driving({ ignition: 2 }), 316, 316 + SWEEP_MS);
expect(Math.max(...sweep)).toBeGreaterThan(CRUISE_KMH * 1.6);
const settle = run(model, driving(), 316 + SWEEP_MS + 16, 5_000);
const last = settle[settle.length - 1];
expect(last).toBe(CRUISE_KMH);
expect(model.step(5_016, driving(), true)).toMatchObject({ kmh: CRUISE_KMH, gear: '4', settled: true });
});
it('overshoots like a real needle, then comes to rest', () => {
const model = createSpeedometerModel();
run(model, driving(), 0, 4_000);
const push = driving({ driveSpeed: 1, direction: 1, acceleration: 1 });
const trace = run(model, push, 4_016, 8_000);
const peak = Math.max(...trace);
expect(peak).toBeGreaterThan(MAX_KMH);
expect(peak).toBeLessThan(MAX_KMH * 1.2);
expect(trace[trace.length - 1]).toBe(MAX_KMH);
expect(model.step(8_016, push, true)).toMatchObject({ throttle: 1, brake: false, settled: true });
});
it('never reads below zero, even on the fall after a jump', () => {
const model = createSpeedometerModel();
run(model, driving({ jump: true }), 0, 3_000);
const fall = run(model, driving({ arrived: true }), 3_016, 7_000);
expect(Math.min(...fall)).toBeGreaterThanOrEqual(0);
expect(fall[fall.length - 1]).toBe(0);
expect(model.step(7_016, driving({ arrived: true }), true)).toMatchObject({ engine: 'off', gear: 'P', settled: true });
});
it('snaps without a sweep when not animated (reduced motion)', () => {
const model = createSpeedometerModel();
expect(model.step(0, driving({ ignition: 0 }), false).kmh).toBe(0);
const first = model.step(16, driving(), false);
expect(first.kmh).toBe(CRUISE_KMH);
expect(first.settled).toBe(true);
expect(model.step(32, driving({ driveSpeed: 1, direction: 1, acceleration: 1 }), false).kmh).toBe(MAX_KMH);
});
it('lights the brake lamp on a reverse scroll and the throttle on a push', () => {
const model = createSpeedometerModel();
const brake = model.step(0, driving({ direction: -1, acceleration: 0.6 }), true);
expect(brake.brake).toBe(true);
expect(brake.throttle).toBeCloseTo(0.6, 6);
expect(model.step(16, driving({ direction: 1, acceleration: 0.3 }), true)).toMatchObject({ brake: false, throttle: 0.3 });
expect(model.step(32, driving({ direction: -1, acceleration: 0.6, jump: true }), true).brake).toBe(false);
});
it('clamps a long gap between frames to one small step', () => {
const model = createSpeedometerModel();
model.step(0, driving(), true);
const after = model.step(60_000, driving(), true).kmh;
expect(after).toBeGreaterThan(0);
expect(after).toBeLessThan(CRUISE_KMH);
});
it('reuses one frame object and tolerates non-finite input', () => {
const model = createSpeedometerModel();
const a = model.step(0, driving(), true);
const b = model.step(16, driving({ driveSpeed: Number.NaN, acceleration: Number.NaN, routeProgress: Number.NaN }), true);
expect(b).toBe(a);
expect(Number.isFinite(b.kmh)).toBe(true);
expect(Number.isFinite(b.tripKm)).toBe(true);
});
});
describe('createBeamFlashDetector', () => {
const ramp = (from: number, to: number, steps: number): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
it('ignores the intro sweep and its fall to the rest level', () => {
const flash = createBeamFlashDetector();
const seen = [...ramp(0, 1, 40), ...ramp(1, 0.72, 20)].map(flash);
expect(seen.some(Boolean)).toBe(false);
});
it('fires on a lift from rest and holds while the beam is high', () => {
const flash = createBeamFlashDetector();
for (const v of [...ramp(0, 1, 40), ...ramp(1, 0.72, 20)]) flash(v);
const lift = [...ramp(0.72, 1, 6), 1, 1, 1, ...ramp(1, 0.72, 10)].map(flash);
expect(lift.filter(Boolean).length).toBeGreaterThanOrEqual(3);
expect(lift[lift.length - 1]).toBe(false);
// Settled back at rest, the next lift fires again.
expect([...ramp(0.72, 1, 6)].map(flash).some(Boolean)).toBe(true);
});
it('does not fire from a dark beam, nor from a lift straight out of the dark', () => {
const flash = createBeamFlashDetector();
expect([0, 0, 0, 0, ...ramp(0, 1, 5), 1, 1].map(flash).some(Boolean)).toBe(false);
});
});