mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/e-satellite-launch-year
This commit is contained in:
commit
6a01355560
34 files changed
+405
-56
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+13
-2
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user