Merge branch 'main' into fix/e-satellite-launch-year

This commit is contained in:
Sameh Khamis committed 2026-11-01 23:51:11 -08:00
commit 6a01355560
34 files changed
+405 -56

No files matched your search

+6 -7
View File
@@ -10,6 +10,7 @@ import {
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
@@ -31,13 +32,11 @@ function presentAwarenessCount(summary = {}) {
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
const parts = distanceParts(meters);
if (!parts) return '—';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
+7 -12
View File
@@ -31,6 +31,7 @@ import {
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
import { distanceParts } from '../../data/distanceFormat.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -108,18 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250;
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
// Pick the unit from the tenth it prints: 9.96 km read "10.0 km".
return Math.round(meters / 100) < 100
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 });
if (!parts) return '';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
/**
+5 -2
View File
@@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
0,
Number(preCountdownDurationSec) || 0,
);
// The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5.
const preCountdownActive =
preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration;
preCountdownDuration > 0 &&
realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC;
const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive;
const countdownSeconds = countdownActive
? Math.ceil(-realSecondsSinceStart)
@@ -247,7 +249,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
? layerState._replayPausedAtMs
: Date.now();
for (const [launchId, startedAt] of layerState._animationStarts) {
if (!Number.isFinite(startedAt)) continue;
// A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x.
if (!Number.isFinite(startedAt) || startedAt > now) continue;
const elapsedMissionMs = (now - startedAt) * previousSpeed;
layerState._animationStarts.set(
launchId,
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createReplay } from './replay.js';
import { createPaths } from './paths.js';
const noop = () => {};
function replayRig() {
const state = createState({
services: {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
},
});
const parts = { panel: { publishPanel: noop } };
parts.paths = createPaths({ state, parts });
return { state, replay: createReplay({ state, parts }) };
}
test('changing replay speed during the countdown keeps its real-time liftoff', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
const { state, replay } = replayRig();
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
state._animationStarts.set('countdown', 1_008_000); // T-8
state._animationStarts.set('flying', 990_000); // 10 s into the mission
replay.setReplaySpeed(0.25);
const startedAt = state._animationStarts.get('countdown');
assert.equal(startedAt, 1_008_000);
const countdown = replay.replayState(
launch,
startedAt,
12,
28,
5400,
state._replaySpeed,
Date.now(),
);
assert.equal(countdown.countdownSeconds, 8);
// Mission time already flown is still preserved across the change.
assert.equal(state._animationStarts.get('flying'), 960_000);
});
+13 -2
View File
@@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) {
* `error` carries this instant's health, so a live-configured layer whose
* flow feed went down reads DEGRADED with the reason instead of a stale
* LIVE coverage number. `flowCoveragePct` is shown dots on matched roads /
* all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests
* all shown dots (0–100 int, kept within 1–99 while both kinds are shown),
* excluding closures; `tilesFetched` counts flow-tile requests
* issued to the proxy this session (decode-cache hits excluded).
* @returns {{count:number, lastUpdate:number|null, loading:boolean,
* mode:'live'|'sim', error:string|null, flowCoveragePct:number,
@@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) {
Boolean(layerState._heightPending);
const { free, slow, jam, sim } = layerState._bucketCounts;
const matched = free + slow + jam;
// The label reads 0 as "no matches" and 100 as nothing simulated, so
// rounding must not reach either while both kinds of dot are shown.
const flowCoveragePct =
matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0;
matched + sim > 0
? Math.min(
sim > 0 ? 99 : 100,
Math.max(
matched > 0 ? 1 : 0,
Math.round((100 * matched) / (matched + sim)),
),
)
: 0;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
+20
View File
@@ -516,6 +516,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and
assert.equal(controls.getStats().flowCoveragePct, 80);
});
test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => {
const { state, controls } = await matchingContext(async () => []);
state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 1);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 };
assert.equal(controls.getStats().flowCoveragePct, 99);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 };
assert.equal(controls.getStats().flowCoveragePct, 100);
state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 0);
});
test('refresh failure removes cached matches and reports simulated flow', async () => {
const { flow, state, model, controls } = await matchingContext(async () => {
throw new Error('HTTP 429');