Merge branch 'polish/speedometer' into redesign/overnight
This commit is contained in:
@@ -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
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
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
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) {
|
||||
|
||||
Reference in New Issue
Block a user