mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
merge main 17daf897 into feat/voice-outlines
This commit is contained in:
commit
c059e12a11
107 files changed
+1598
-167
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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°',
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ?? '--',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user