merge main 17daf897 into feat/voice-outlines

This commit is contained in:
Sameh Khamis committed 2026-11-02 09:06:21 -08:00
commit c059e12a11
107 files changed
+1598 -167

No files matched your search

+2 -2
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -49,9 +50,8 @@ export function alprLabelDetails(record, source) {
const details = [
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
// Wrap after rounding: 359.6° rounds to 360°, which is north.
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
+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,
});
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
@@ -5,6 +7,5 @@
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
+59
View File
@@ -451,3 +451,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col
assert.equal(model.toNonNegativeInteger('3'), 3);
}
});
test('a station without a published capacity re-derives it from every status poll', async () => {
const { createRendering } = await import('./rendering.js');
const { COLOR_YELLOW } = await import('./policy.js');
const model = createModel({});
const state = {
_cityRuntime: new Map(),
_stationRenderMap: new Map(),
_pointCollection: { add: (options) => ({ ...options }) },
};
const rendering = createRendering({
state,
services: { render: { governorRequestRender() {} } },
parts: { model },
});
const info = model.parseStationInformation({
data: {
stations: [
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
{
station_id: 'fixed',
name: 'Fixed',
lat: 30.27,
lon: -97.75,
capacity: 13,
},
],
},
});
rendering.ensureCityPoints('austin', info);
const poll = (stations) =>
rendering.applyStatusToPoints(
'austin',
model.parseStationStatus({ data: { stations } }),
);
const open = state._stationRenderMap.get('austin:open');
const fixed = state._stationRenderMap.get('austin:fixed');
// The first poll has no row for the station: capacity is a placeholder.
poll([
{
station_id: 'fixed',
num_bikes_available: 4,
num_docks_available: 9,
},
]);
poll([
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
{ station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 },
]);
assert.equal(open.capacity, 30, 'the first poll fixed the capacity');
assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty');
assert.equal(fixed.capacity, 13, 'a published capacity is kept');
poll([
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
]);
assert.equal(open.capacity, 6);
});
+3 -1
View File
@@ -185,6 +185,7 @@ export function createRendering({
stationName: station.name,
point,
capacity: parts.model.toNonNegativeInteger(station.capacity),
publishedCapacity: parts.model.toNonNegativeInteger(station.capacity),
bikesAvailable: null,
docksAvailable: null,
isInstalled: station.isInstalled,
@@ -245,7 +246,8 @@ export function createRendering({
if (!record) continue;
const status = statusMap.get(record.stationId) || null;
const capacity = resolveCapacity(record.capacity, status);
// Resolve from the published capacity, not last poll's derived value.
const capacity = resolveCapacity(record.publishedCapacity, status);
record.capacity = capacity;
record.bikesAvailable = parts.model.toNonNegativeInteger(
+2 -2
View File
@@ -1,4 +1,5 @@
import { t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* @module headingConfidence
@@ -44,8 +45,7 @@ export function isHeadingEstimated(camera) {
* @returns {string}
*/
export function headingHudToken(camera) {
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360;
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
+7 -11
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,17 +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;
return km < 10
? 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,
});
}
/**
+22
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createCivilFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createEnrichment } from './enrichment.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
await new Promise((done) => setImmediate(done));
assert.equal(flightState._enrichActive, 0);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: services(),
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
_contactLabel: () => 'AAL1',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('abc123').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -24,6 +24,7 @@ import {
TRAIL_COLOR,
CYAN_TRANSPARENT,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -126,7 +127,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
route: route || '',
icao24,
+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);
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const DASH = '—';
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track % 360}°`,
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+25
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: stubs,
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
_toCleanText: (value) => value || '',
_formatAltitude: () => '',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('ae1234').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -25,6 +25,7 @@ import {
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -120,7 +121,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
+2 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -123,6 +123,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
name: entry.name,
satrec,
group: 'dense',
launchYear: tleLineLaunchYear(entry.line1),
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
+3 -1
View File
@@ -1,7 +1,7 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -129,6 +129,8 @@ export function createIngestion({
name: entry.name,
satrec,
group: entry.group,
// satellite.js does not keep the designator the year match reads.
launchYear: tleLineLaunchYear(entry.line1),
});
coreStale ||= entry.stale;
+42 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTleText } from '../../sources/tle.js';
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
import { createIngestion } from './ingestion.js';
import { createOrbits } from './orbits.js';
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
const ISS_LINE1 =
@@ -78,3 +79,43 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
assert.equal(state._count, 3);
});
test('a mission payload does not match a loaded satellite from another launch year', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
// SXM-11, international designator 2026-148A.
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
const track = (launchTime) =>
getSatelliteOrbitTrack('Sirius SXM-11', { launchTime });
assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728);
assert.equal(track('2030-06-29T02:25:00Z'), null);
});
test('a satellite with a blank designator keeps matching by name', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
assert.equal(tleLineLaunchYear('1 69728U 26208.65166678'), null);
assert.equal(
getSatelliteOrbitTrack('Sirius SXM-11', {
launchTime: '2030-06-29T02:25:00Z',
})?.noradId,
69728,
);
});
+6 -16
View File
@@ -8,7 +8,11 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleLineLaunchYear,
} from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -258,13 +262,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return score;
}
function internationalDesignatorYear(satrec) {
const match = String(satrec?.intldesg || '').match(/^(\d{2})/);
if (!match) return null;
const shortYear = Number(match[1]);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function lookupTleEntries(tleText) {
if (tleText !== layerState._lookupTleText) {
layerState._lookupTleText = tleText;
@@ -273,12 +270,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return layerState._lookupTleEntries;
}
function tleLineLaunchYear(line1) {
const shortYear = Number(String(line1 || '').slice(9, 11));
if (!Number.isFinite(shortYear)) return null;
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date();
const current = propagatePosition(satrec, referenceDate);
@@ -350,7 +341,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
: null;
let bestScore = 0;
for (const [id, sat] of layerState._catalog) {
const designatorYear = internationalDesignatorYear(sat.satrec);
const designatorYear = sat.launchYear ?? null;
if (
launchYear !== null &&
designatorYear !== null &&
@@ -379,7 +370,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
tleLineLaunchYear,
orbitTrackFromRecord,
+14
View File
@@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a
);
});
test('dense extras keep their launch year for mission orbit matching', async (t) => {
const f = await fixture(t);
await f.update();
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
// Every fixture set carries the 1998-067A designator.
const track = (launchTime) =>
f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', {
launchTime,
});
assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551);
assert.equal(track('2026-06-29T02:25:00Z'), null);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
+30 -4
View File
@@ -6,6 +6,21 @@ import {
} from './geometry.js';
import * as Cesium from 'cesium';
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
function indexedCollection(features) {
return {
type: 'FeatureCollection',
features: features.map((feature, index) => ({
...feature,
properties: {
...feature.properties,
[SOURCE_FEATURE_INDEX]: index,
},
})),
};
}
export function createIngestion({ state, parts, source }) {
async function load(viewer) {
if (state._loading || state._loaded) return;
@@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) {
const landingFeatures = normalizeFeatures(landingJson, 'landing');
const cableDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: cableFeatures },
indexedCollection(cableFeatures),
{
clampToGround: true,
stroke: state.cableColor.withAlpha(0.95),
@@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) {
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: landingFeatures },
indexedCollection(landingFeatures),
{
clampToGround: true,
stroke: state.landingColor.withAlpha(0.9),
@@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) {
state._referenceRecords = [];
state._surfaceRecords = [];
cableEntities.forEach((entity, index) => {
// Cesium expands multipart geometry into several entities and suffixes
// colliding IDs. Its copied properties retain this source index even
// when a provider has no ID, so every segment keeps the right owner.
const cableReferences = new Set();
cableEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = cableFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (cableReferences.has(index)) return;
cableReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
@@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) {
});
});
landingEntities.forEach((entity, index) => {
const landingReferences = new Set();
landingEntities.forEach((entity) => {
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
const feature = landingFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
@@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) {
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
if (landingReferences.has(index)) return;
landingReferences.add(index);
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
+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
@@ -519,6 +519,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');
+2 -2
View File
@@ -9,6 +9,7 @@ import {
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion, playbackPosition } from './movement.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -405,11 +406,10 @@ export function buildTransitSelectionCopy(
reportedMotion.push(
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
);
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(record.bearing))
reportedMotion.push(
t('reported hdg {heading}°', {
heading: Math.round(record.bearing) % 360,
heading: roundHeadingDegrees(record.bearing),
}),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
+6 -5
View File
@@ -4,6 +4,7 @@ import {
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createCards({
vesselState,
@@ -55,10 +56,8 @@ export function createCards({
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
// A course of 359.5° and above rounds to north; modulo keeps it from
// printing 360°. The selected card and the HUD wrap the same way.
if (Number.isFinite(direction))
parts.push(`${Math.round(direction) % 360}°`);
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
@@ -89,7 +88,9 @@ export function createCards({
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°',
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
@@ -179,7 +180,7 @@ export function createCards({
function formatHeading(heading) {
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--',
heading: roundHeadingDegrees(heading) ?? '--',
});
}