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.
This commit is contained in:
2026-10-12 18:44:05 +03:00
parent 71465fa481
commit 70b30fa200
33 changed files with 929 additions and 2 deletions

View File

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

View File

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