Merge branch 'polish/speedometer' into redesign/overnight
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';
|
if (/^assets\/traffic-pass-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
||||||
// PARKING LIGHTS: the favicon renderer, loaded after mount.
|
// PARKING LIGHTS: the favicon renderer, loaded after mount.
|
||||||
if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video';
|
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 (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ onUnmounted(() => removePageListeners());
|
|||||||
:resume-id="resume?.id"
|
:resume-id="resume?.id"
|
||||||
:allow-motion="motion.policy.value.allowSceneRequest"
|
:allow-motion="motion.policy.value.allowSceneRequest"
|
||||||
:on-section-link-click="navigation.onSectionLinkClick"
|
:on-section-link-click="navigation.onSectionLinkClick"
|
||||||
|
:frames="journeyBinding"
|
||||||
|
:navigation="navigation.state.value"
|
||||||
|
:sample-beam="headlight.sampleBeam"
|
||||||
|
:animated="motion.policy.value.reason === 'full'"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import type { SectionId } from "@/content/types";
|
import type { SectionId } from "@/content/types";
|
||||||
import type { JourneySnapshot } from "@/journey/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";
|
import { SIGN_ACCENTS } from "@/journey/sign-routes";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -13,6 +17,12 @@ const props = defineProps<{
|
|||||||
/** A returning visitor's last exit: the window offers it from the start. */
|
/** A returning visitor's last exit: the window offers it from the start. */
|
||||||
resumeId?: SectionId | undefined;
|
resumeId?: SectionId | undefined;
|
||||||
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
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>> = {
|
const navigationLabels: Readonly<Record<SectionId, string>> = {
|
||||||
@@ -128,6 +138,31 @@ const driveOn = (): void => {
|
|||||||
list.value?.querySelectorAll<HTMLAnchorElement>("a")[nextIndex.value]?.click();
|
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 => {
|
const navigate = (event: MouseEvent): void => {
|
||||||
props.onSectionLinkClick(event, true);
|
props.onSectionLinkClick(event, true);
|
||||||
if (window.matchMedia("(max-width: 767px)").matches)
|
if (window.matchMedia("(max-width: 767px)").matches)
|
||||||
@@ -137,6 +172,37 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
|
|
||||||
<template>
|
<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'] }">
|
<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>
|
<details ref="disclosure" class="site-navigation__disclosure" open>
|
||||||
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
||||||
<ul ref="list">
|
<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;
|
mask-composite: exclude, add;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No brand plate in the left bay: the hero carries the name (owner's call:
|
/* ---------- SPEEDOMETER in the left bay ----------
|
||||||
a name in the nav bar read as self-promotion). */
|
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 ---------- */
|
/* ---------- Exits on the beam ---------- */
|
||||||
@media (min-width: 768px) {
|
@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.js', 'postcard')).toBe('deferred-non-video');
|
||||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).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/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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||