feat: put a speedometer in the gantry's left bay
A 240-degree SVG dial with a spring needle, three-digit readout, unit label, warning lamp (BRAKE / OVERDRIVE / ENGINE OFF), trip odometer and gear chip, driven by the journey's own frame loop from the scroll drive, route progress, navigator state, scene ignition and the high beam. Hover or focus shows route, exit and time on the road; a click toggles km/h and mph (remembered). The model and DOM driver load as a lazy chunk so the initial bundle grows by about 1.3 kB gzipped; reduced motion snaps the readout, and the bay hides below 1280px.
BIN
docs/overnight/polish/speedo-1280-destination-bay.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
docs/overnight/polish/speedo-1280-destination.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/overnight/polish/speedo-1280-flash.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
docs/overnight/polish/speedo-1280-hover.webp
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
docs/overnight/polish/speedo-1280-idle-bay.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
docs/overnight/polish/speedo-1280-idle.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
docs/overnight/polish/speedo-1280-ignition-strip.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/overnight/polish/speedo-1280-jump-strip.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/overnight/polish/speedo-1280-jump.webp
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/overnight/polish/speedo-1280-scroll-strip.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/overnight/polish/speedo-1280-scrolling.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/overnight/polish/speedo-1440-destination-bay.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
docs/overnight/polish/speedo-1440-destination.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/overnight/polish/speedo-1440-flash.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
docs/overnight/polish/speedo-1440-hover.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
docs/overnight/polish/speedo-1440-idle-bay.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
docs/overnight/polish/speedo-1440-idle.webp
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
docs/overnight/polish/speedo-1440-ignition-strip.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/overnight/polish/speedo-1440-jump-strip.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/overnight/polish/speedo-1440-jump.webp
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/overnight/polish/speedo-1440-scroll-strip.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/overnight/polish/speedo-1440-scrolling.webp
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
docs/overnight/polish/speedo-lamps-and-focus.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/overnight/polish/speedo-reduced-motion-bay.webp
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
90
src/composables/useSpeedometer.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
150
src/journey/speedometer-dial.ts
Normal 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
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
217
tests/unit/speedometer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||