Merge branch 'main' into feat/transit-icon-heading

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-10 12:24:46 -08:00
commit 05daaf267a
191 files changed
+16103 -740

No files matched your search

+18 -1
View File
@@ -1,6 +1,11 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import {
claimPointer,
isPointerFree,
releasePointer,
} from '../data/inputOwnership.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -245,5 +250,17 @@ export function createApplicationTools({
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
// An application's own tool takes the pointer as Draw does, and its own
// click handlers yield while a tool holds it, as the layers' do
// (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
pointer: {
claim: claimPointer,
release: releasePointer,
isFree: isPointerFree,
},
};
}
+39
View File
@@ -1802,3 +1802,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
test('detection objects name the vessel card their callout gives way to', () => {
const vessel = (mmsi, name) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
const keyed = vessel('200', 'GOLDEN BEAR');
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
_setVesselStateForTest({
viewer: {},
records: [keyed, unkeyed],
billboardCollection: { show: true, remove() {} },
});
try {
const objects = Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
assert.equal(
objects['NO MMSI'].cardEntryId,
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
'the same identity the vessel card is published under',
);
} finally {
_setVesselStateForTest({ enabled: false });
}
});
+21
View File
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
h.restore();
}
});
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
const record = normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: {
'surveillance:type': 'ALPR',
operator: ' Şehir\n\u0000 PD ',
manufacturer: '😀'.repeat(200),
'camera:type': null,
'surveillance:zone': '\t traffic zone ',
},
});
assert.equal(record.operator, 'Şehir PD');
assert.equal(record.manufacturer.length, 180);
assert.equal(record.manufacturer, '😀'.repeat(90));
assert.equal(record.cameraType, null);
assert.equal(record.zone, 'traffic zone');
});
+31 -4
View File
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'planespotters',
html:
'Aircraft photos: ' +
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
'· each photo is © its photographer, credited beside it',
},
{
key: 'aisstream',
html:
@@ -673,20 +680,40 @@ export const OSM_PLACES_CREDIT = {
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
*
* Two optional registry fields serve licences that ask for more than a name:
* `sourceUrl` links the attribution to the publisher (Warsaw asks for a link
* to its API), and `creditDated` adds the UTC date of the data, from the feed
* header or else the fetch time (Warsaw and Poznań ask for the download or
* update date). A dated credit is keyed by its date, so a new day registers a
* new line instead of keeping yesterday's.
* @param {{ id: string, attribution: string, license: string, licenseUrl: string,
* sourceUrl?: string, creditDated?: boolean }} feed
* @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot]
* `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms.
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
export function transitFeedCredit(feed, snapshot = null) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const source = feed.sourceUrl
? `<a href="${escape(feed.sourceUrl)}" target="_blank" rel="noopener">${escape(feed.attribution)}</a>`
: escape(feed.attribution);
const dataMs = Number.isFinite(snapshot?.feedTimestamp)
? snapshot.feedTimestamp * 1000
: snapshot?.fetchedAt;
const date =
feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0
? new Date(dataMs).toISOString().slice(0, 10)
: null;
return {
key: `transit-${feed.id}`,
key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
html:
`Transit (${escape(feed.attribution)}): ` +
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
+34 -1
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
import {
DATA_CREDITS,
NYC_DOT_DISCLAIMER,
transitFeedCredit,
} from './dataCredits.js';
import { getTransitFeed } from './transitFeeds.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -38,6 +43,13 @@ test('adsbdb is credited and carries its published route-data restriction', () =
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
test('Planespotters is credited with a plain followable link', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'planespotters');
assert.ok(credit, 'the tracked aircraft photos come from Planespotters');
assert.match(credit.html, /href="https:\/\/www\.planespotters\.net"/);
assert.doesNotMatch(credit.html, /nofollow/);
});
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
// UCS database manual, section 3: references name the version (the Excel
// file's name) and link www.ucsusa.org/satellite_database.
@@ -190,3 +202,24 @@ test('the PR #1506 camera packs are credited with their sources and terms', () =
/href="https:\/\/www\.river\.go\.jp\//,
);
});
test('Warsaw and Poznań credits carry the data date their licences ask for', () => {
const warsaw = getTransitFeed('ztm-warszawa');
// 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds).
const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 });
assert.match(credit.html, /, data of 2026-11-09\)/);
assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/);
assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09');
// Without a feed timestamp the fetch time is the download date.
const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), {
feedTimestamp: null,
fetchedAt: Date.UTC(2026, 10, 10, 12),
});
assert.match(poznan.html, /data of 2026-11-10/);
// Undated feeds keep one credit line for the session.
const mbta = transitFeedCredit(getTransitFeed('mbta'), {
feedTimestamp: 1_794_204_000,
});
assert.equal(mbta.key, 'transit-mbta');
assert.doesNotMatch(mbta.html, /data of/);
});
+24 -3
View File
@@ -21,7 +21,10 @@ import {
getKeyholeFadeTuning,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
import {
getQueuedOverlayEntryAlpha,
registerWorldOverlayPaintLane,
} from '../overlays/worldOverlay.js';
import {
DETECTION_STYLE,
DETECTION_THEME_MAP,
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
const SPARSE_ZONE_FRACTION = 0.5;
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4;
/** Finer bands for Global Context echo brackets, whose fade is animated. */
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
.filter((row) => row.paintAlpha > 0.001)
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
return result;
}
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
cardSourceId: '',
cardEntryId: '',
paintAlpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
row.font = FONT;
row.microFont = MICRO_FONT;
row.alpha = alpha;
row.cardSourceId = candidate.cardSourceId;
row.cardEntryId = candidate.cardEntryId;
_calloutCount++;
}
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
const row = _calloutPool[i];
paintDetectionCallout(ctx, row, row.alpha);
// The callout would show through its contact's own card: fade it by the
// opacity that card paints with this frame.
const cardAlpha =
row.cardEntryId === ''
? 0
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
row.paintAlpha =
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
}
ctx.globalAlpha = 1;
}
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
primary,
micro,
hasMicro: card.hasMicro,
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
};
}
+179
View File
@@ -20,9 +20,16 @@ import {
} from './detection.js';
import {
destroyWorldOverlay,
getOverlayPaintRect,
initWorldOverlay,
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import {
getKeyholeFadeTuning,
getKeyholeGeometry,
keyholeLabelAlphaFromGeometry,
} from '../celestialRing.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
}
});
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
function shipWithCard(mmsi, position) {
return {
position,
sourceId: mmsi,
id: `SHIP-${mmsi}`,
metric: '6 kn',
type: 'SEA',
cardSourceId: 'ais-live-vessels',
cardEntryId: `vessel:${mmsi}`,
};
}
/** The production ambient vessel card (not protected: it goes through the arbiter). */
function ambientVesselCard(mmsi, position) {
return applyVesselOverlayPolicy({
id: `vessel:${mmsi}`,
position,
title: `CARD-${mmsi}`,
details: ['CARGO · 6KT · 80°'],
gapPx: 10,
});
}
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
// Default keyhole tuning: 7% feather, 1% outside opacity.
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
const width = 1600;
const height = 900;
const keyhole = getKeyholeGeometry(width, height);
const env = installEnvironment({ width, height, dpr: 1 });
// Just outside the keyhole's left rim: the card sits above or below the
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
const ship = shipWithCard('200', position);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
for (let i = 0; i < 4; i++) settleFrame(env);
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
assert.ok(rect, 'the host paints the card (at the outside opacity)');
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
const callouts = getDetectionDiagnostics().calloutRects;
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
const ship = shipWithCard('200', at(0.1, 0.1));
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
const frame = (ms) => {
env.ctx.calls.length = 0;
env.advance(ms);
env.postRender.raise();
};
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
for (let i = 0; i < 6; i++) frame(250);
frame(16);
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
// The vessel layer republishes its screen-grid cohort after a camera move:
// this vessel's card is replaced by another one, the vessel stays detectable.
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
frame(16);
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
assert.ok(painted('CARD-200'), 'and its text still paints');
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
for (let i = 0; i < 4; i++) frame(250);
assert.ok(!painted('CARD-200'), 'the card has faded out');
assert.ok(painted('SHIP-200'), 'and the callout is back');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a vessel whose ambient card loses the collision keeps its callout', () => {
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
// Four vessels a few pixels apart: their cards collide (a card goes only
// above or below its anchor), so the arbiter paints only some of them.
const ships = [0, 1, 2, 3].map((i) =>
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
);
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
setOverlayEntries(
'ais-live-vessels',
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
);
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
env.ctx.calls.length = 0;
settleFrame(env);
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
assert.ok(
winners.length >= 1 && winners.length < ships.length,
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
);
for (const ship of ships) {
const carded = winners.includes(ship);
assert.equal(
painted(ship.id),
!carded,
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
);
}
assert.equal(
getDetectionDiagnostics().calloutRects.length,
ships.length - winners.length,
'only the callouts that painted are reported',
);
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('a callout fades out while its card fades in instead of vanishing at once', () => {
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
try {
initWorldOverlay(env.viewer);
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
setMode('DENSE');
for (let i = 0; i < 4; i++) settleFrame(env);
const [alone] = getDetectionDiagnostics().calloutRects;
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
const alphas = [];
for (let i = 0; i < 30; i++) {
env.advance(16);
env.postRender.raise();
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
}
assert.ok(
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
);
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
} finally {
destroyDetection();
destroyWorldOverlay();
env.cleanup();
}
});
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
// Satellites sit over the high-albedo lit Earth disc far more often than
// aircraft do, so the space tier carries the heavier backing. Losing the
+11
View File
@@ -0,0 +1,11 @@
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
export function normalizeDisplayText(value, maxLength = 180) {
if (value == null || typeof value === 'object') return '';
return String(value)
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
.replace(/[\uD800-\uDBFF]$/, '')
.trim();
}
+4 -3
View File
@@ -120,9 +120,10 @@ test('fire detection key survives a refetch that renumbers the index', () => {
});
test('two satellites over the same pixel at the same time stay distinct', () => {
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
// overlap, so position and time alone are NOT unique. Collapsing them let a
// restored selection land on the other satellite's record.
// The proxy merges NOAA-20 + NOAA-21 without dedup and their orbits overlap,
// so position and time alone are NOT unique. Collapsing them let a restored
// selection land on the other satellite's record. Archived Suomi NPP (`N`)
// rows must stay distinct too.
const base = makeFire({ satellite: 'N20' });
const twin = makeFire({ satellite: 'N21' });
const suomi = makeFire({ satellite: 'N' });
+14 -5
View File
@@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
// Read the list from production so these tests follow it.
const listed = config.match(/const SOURCES = \[([^\]]*)\];/);
assert.ok(listed, 'FIRMS source list exists');
const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]);
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
@@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0, 0][index], ok: true,
source, count: [200_000, 1, 0][index], ok: true,
})));
});
@@ -63,7 +66,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 3);
assert.equal(result.fires.length, SOURCES.length - 1);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
@@ -77,7 +80,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 3);
assert.equal(result.fires.length, SOURCES.length - 1);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
@@ -107,7 +110,7 @@ function wrappedAddressSelectionFailure() {
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
@@ -307,3 +310,9 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () =>
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
test('FIRMS requests only the satellites NASA still delivers', () => {
// NASA stopped delivering Suomi NPP on 2026-11-02; asking for it only spends
// a MAP_KEY transaction.
assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT'));
});
+2
View File
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
flightsLayer.testing._driveFleetModelHandoffForTest;
export const _ensureFleetModelForTest =
flightsLayer.testing._ensureFleetModelForTest;
export const _resetEnrichmentForTest =
flightsLayer.testing._resetEnrichmentForTest;
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
export default flightsLayer;
+293 -2
View File
@@ -1,7 +1,7 @@
// src/data/flights.test.mjs
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
// Pure function — no viewer/DOM needed; imported directly.
import { test } from 'node:test';
import { after, afterEach, before, test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import flightsLayer, {
@@ -14,21 +14,53 @@ import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
_floorGroundedDisplayPositionForTest,
_clearDisplayFloorStateForTest,
_resetEnrichmentForTest,
mapAnalystRecord,
} from './flights.js';
import { setMilitaryLayerActive } from './militaryRegistry.js';
import {
isMilitaryLayerActive,
registerMilitaryIcaos,
setMilitaryLayerActive,
} from './militaryRegistry.js';
import {
GROUND_FLOOR_LIFT_M,
reportMeshFloorCell,
reportValidatedMeshFloorCell,
setMeshFloorPreferred,
_clearMeshFloorCellsForTest,
} from './groundFloor.js';
import { resolveEllipsoidalGround } from './terrainHeights.js';
import {
_setTrackedOverlayHostForTest,
destroyTrackedReadout,
initTrackedReadout,
} from './trackedReadout.js';
// Unit tests never reach the network. A test that tracks a plane queues its
// enrichment (type, route, photo), so every test ends on an empty queue, and a
// fetch of any absolute URL fails the test it happened in. A test that needs
// answers installs its own fetch and restores this one.
const nodeFetch = globalThis.fetch;
const networkRequests = [];
before(() => {
globalThis.fetch = async (url) => {
const href = String(url?.url ?? url);
if (/^https?:\/\//i.test(href)) networkRequests.push(href);
throw new TypeError(`Failed to parse URL from ${href}`);
};
});
afterEach(() => {
_resetEnrichmentForTest();
assert.deepEqual(
networkRequests.splice(0),
[],
'no test reaches the network',
);
});
after(() => {
globalThis.fetch = nodeFetch;
});
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
assert.equal(
@@ -402,6 +434,232 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
}
});
test('a tracked plane whose photo lookup failed asks again on the first poll after the cool-down', async (t) => {
const icao24 = 'b2c3d4';
const realNow = Date.now.bind(Date);
let offsetMs = 0;
t.mock.method(Date, 'now', () => realNow() + offsetMs);
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
icao24,
entity,
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
color: Cesium.Color.WHITE,
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
meta: {
callsign: 'UAL9',
altitude: 9_000,
renderAltitudeM: 9_050,
velocity: 180,
true_track: 80,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.2,
rawLon: -97.7,
},
});
let callsign = 'UAL9 ';
const photoRequests = [];
const routeRequests = [];
const guardFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
url = String(url);
if (url.startsWith('https://api.planespotters.net/')) {
photoRequests.push(url);
return { ok: false, status: 503, json: async () => ({}) };
}
if (url.startsWith('/api/adsbdb/route/UAL')) routeRequests.push(url);
if (!url.startsWith('/api/flights'))
return { ok: true, status: 200, json: async () => ({ found: false }) };
const nowSec = Math.floor(Date.now() / 1000);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
time: nowSec,
states: [
[
icao24,
callsign,
'United States',
nowSec,
nowSec,
-97.6,
30.3,
10_668,
false,
250,
95,
5,
null,
10_700,
null,
null,
null,
5,
],
],
}),
};
};
// Lookups drip out one per 200 ms. Wait for what must arrive (with a
// generous deadline, so a loaded machine cannot fail it), then a little
// longer so anything that must NOT arrive would have.
const until = async (ready) => {
const deadline = realNow() + 5_000;
while (!ready() && realNow() < deadline)
await new Promise((done) => setTimeout(done, 20));
await new Promise((done) => setTimeout(done, 450));
};
try {
await flightsLayer.update(viewer);
await until(() => photoRequests.length >= 1 && routeRequests.length >= 1);
assert.equal(photoRequests.length, 1, 'the first poll asks for the photo');
assert.deepEqual(routeRequests, ['/api/adsbdb/route/UAL9']);
// Well inside the 60 s cool-down, and well past it below, so real time
// spent between polls cannot move either side across the line.
offsetMs += 30_000;
callsign = 'UAL10 ';
await flightsLayer.update(viewer);
await until(() => routeRequests.length >= 2);
assert.equal(photoRequests.length, 1, 'not within the cool-down');
assert.deepEqual(
routeRequests,
['/api/adsbdb/route/UAL9', '/api/adsbdb/route/UAL10'],
'the route follows the callsign the poll just recorded',
);
offsetMs += 60_000;
await flightsLayer.update(viewer);
await until(() => photoRequests.length >= 2);
assert.deepEqual(
photoRequests,
[
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
'https://api.planespotters.net/pub/photos/hex/b2c3d4',
],
'asked again on the first poll after the cool-down',
);
} finally {
globalThis.fetch = guardFetch;
// The moved clock dated this layer's dispatch and cool-downs into the
// future; reset before the real clock comes back.
_resetEnrichmentForTest();
t.mock.restoreAll();
}
});
test('a known-military aircraft tracked in the flights layer is never looked up and shows no photo', async () => {
const icao24 = 'ae5e01';
// The registry has no way to forget one hex (only a whole-registry reset,
// which would also drop its source), so the test uses a hex no other test
// reads and restores the layer-active flag it changes.
const militaryWasActive = isMilitaryLayerActive();
registerMilitaryIcaos([icao24]);
setMilitaryLayerActive(false);
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
icao24,
entity: { gevLabelModel: { title: 'OLD', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 9_000),
color: Cesium.Color.WHITE,
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
meta: {
callsign: 'RCH123',
altitude: 9_000,
renderAltitudeM: 9_050,
velocity: 180,
true_track: 80,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.2,
rawLon: -97.7,
// Even a photo already on the record is never shown.
photo: {
src: 'https://t.plnspttrs.net/1/ae5e01_280.jpg',
width: 280,
height: 186,
photographer: 'Jane Doe',
link: 'https://www.planespotters.net/photo/1/x',
},
},
});
const photoRequests = [];
const routeRequests = [];
const guardFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
url = String(url);
if (url.startsWith('https://api.planespotters.net/')) {
photoRequests.push(url);
return { ok: false, status: 503, json: async () => ({}) };
}
if (url.startsWith('/api/adsbdb/route/')) routeRequests.push(url);
if (!url.startsWith('/api/flights'))
return { ok: true, status: 200, json: async () => ({ found: false }) };
const nowSec = Math.floor(Date.now() / 1000);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
time: nowSec,
states: [
[
icao24,
'RCH123 ',
'United States',
nowSec,
nowSec,
-97.6,
30.3,
10_668,
false,
250,
95,
5,
null,
10_700,
null,
null,
null,
5,
],
],
}),
};
};
try {
await flightsLayer.update(viewer);
// A photo lookup would go out ahead of the route; wait for the route.
const deadline = Date.now() + 5_000;
while (!routeRequests.length && Date.now() < deadline)
await new Promise((done) => setTimeout(done, 20));
assert.deepEqual(routeRequests, ['/api/adsbdb/route/RCH123']);
assert.deepEqual(photoRequests, [], 'no photo lookup for a military hex');
assert.equal(flightsLayer.getTrackedInfo()?.icao24, icao24);
assert.equal(flightsLayer.getTrackedInfo()?.photo, null);
assert.equal(flightsLayer.getTrackedSubject()?.photo, null);
} finally {
globalThis.fetch = guardFetch;
setMilitaryLayerActive(militaryWasActive);
}
});
test('real civil track path creates no native label and publishes every cached host line', async () => {
const icao24 = 'civ001';
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
@@ -1181,6 +1439,39 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte
assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6);
});
test('display floor: reads the reading nearest the contact, not the cell centre', async () => {
_clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(true);
const realFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ results: [{ ellipsoid: 120 }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
try {
await resolveEllipsoidalGround([{ lat: 30.317, lon: -97.717 }]);
} finally {
globalThis.fetch = realFetch;
}
// A hangar roof over the cell's centre; the apron where the contact is.
assert.equal(reportValidatedMeshFloorCell(30.317, -97.717, 142), true);
assert.equal(reportValidatedMeshFloorCell(30.3173, -97.7173, 121), true);
const pos = Cesium.Cartesian3.fromDegrees(-97.7173, 30.3173, 100);
const out = _floorGroundedDisplayPositionForTest(
{ onGround: true },
pos,
false,
);
const c = _floorCarto(out);
assert.ok(
Math.abs(c.height - (121 + GROUND_FLOOR_LIFT_M)) < 0.05,
`height ${c.height} should be the apron's 121 + lift`,
);
_clearMeshFloorCellsForTest();
_clearDisplayFloorStateForTest();
});
test('display floor: an AIRBORNE contact over the same cell is never lifted', () => {
_clearDisplayFloorStateForTest();
_clearMeshFloorCellsForTest();
+1 -1
View File
@@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
*/
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
if (!isCalibratedAllocationRuntime()) {
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`);
}
const result = spawnSync(
process.execPath,
+1
View File
@@ -22,6 +22,7 @@ export const {
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
meshFloorPriorReady,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
+17 -1
View File
@@ -2385,11 +2385,27 @@ export class LayerLifecycle {
/**
* Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps).
* @param {string} [layerId] - The layer whose row changed.
*/
refreshLayerStats() {
refreshLayerStats(layerId) {
this._dropRecoveredRefreshError(layerId);
this._publishActivity({ type: 'status' });
}
/**
* Drop a tick failure the layer's stats no longer report (the camera left
* that feed) instead of showing it until the next tick.
*/
_dropRecoveredRefreshError(layerId) {
const entry = layerId ? this.layers.get(layerId) : null;
if (!entry?.managerRefreshError || entry.refreshing) return;
const failure = refreshFailureFromStats(
this._moduleStats(entry),
entry.module.name || layerId,
);
if (!failure) entry.managerRefreshError = null;
}
_publishActivity(change) {
for (const callback of this._activityListeners) {
try {
-10
View File
@@ -15,16 +15,6 @@ export class DataLayerManager extends LayerLifecycle {
this._presentation.refresh();
}
/**
* Repaint the toggle panel now. For layers whose data arrives outside their
* manager tick (camera-driven loads such as Transit's proximity polls), so a
* row shows its count when the data lands instead of at the next interval.
* One DOM pass; skipped while the document is hidden.
*/
refreshLayerStats() {
this._refreshTogglePanel();
}
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
+25
View File
@@ -2677,6 +2677,31 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
assert.deepEqual(changes, ['status']);
});
test('refreshLayerStats(layerId) drops a tick failure the layer no longer reports', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
let stats = { count: 0, error: 'Feed unavailable' };
lifecycle.register({
id: 'feed',
name: 'Feed',
getStats: () => stats,
async update() {},
});
const entry = lifecycle.layers.get('feed');
entry.enabled = true;
entry.initialized = true;
entry.managerRefreshError = 'Feed unavailable';
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, 'Feed unavailable');
stats = { count: 0 };
lifecycle.refreshLayerStats();
assert.equal(entry.managerRefreshError, 'Feed unavailable');
lifecycle.refreshLayerStats('feed');
assert.equal(entry.managerRefreshError, null);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
+178 -1
View File
@@ -24,7 +24,7 @@ import { sampleMeshFloorCells } from './meshFloorSampler.js';
import { resolveEllipsoidalGround } from './terrainHeights.js';
import {
cachedGroundFloor, cachedMeshFloor, setMeshFloorPreferred,
_clearMeshFloorCellsForTest,
_clearMeshFloorCellsForTest, reportValidatedMeshFloorCell,
} from './groundFloor.js';
/** Distinct site per test — terrainHeights' cache is module-global and warm
@@ -137,3 +137,180 @@ test('sampler: a cell just across the antimeridian is still near the viewer', ()
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
test('sampler: the probe goes where the point is, not to the cell centre', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
const asked = [];
const scene = fakeScene((carto) => {
asked.push(Cesium.Math.toDegrees(carto.longitude));
return 122.0;
});
const point = { lat: cell.lat + 0.0003, lon: cell.lon + 0.0004 };
sampleMeshFloorCells(scene, [point], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(asked.length, 1);
assert.ok(Math.abs(asked[0] - point.lon) < 1e-9, `probed at ${asked[0]}, not ${point.lon}`);
});
test('sampler: a viaduct and the street beside it in one cell keep their own heights', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A viaduct deck 9 m up along one line of the cell, the street elsewhere.
const deckLat = cell.lat + 0.0003;
let calls = 0;
const scene = fakeScene((carto) => {
calls += 1;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
const street = { lat: cell.lat - 0.0003, lon: cell.lon };
sampleMeshFloorCells(scene, [onDeck], opts);
sampleMeshFloorCells(scene, [street], opts);
assert.equal(calls, 2);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon + 0.0001), 129.0, 'the train stays on the deck');
assert.equal(cachedMeshFloor(street.lat, street.lon - 0.0001), 121.0, 'the bus stays on the street');
assert.equal(cachedGroundFloor(street.lat, street.lon), 121.0);
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon + 0.00001 }], opts);
assert.equal(calls, 2, 'a point next to one already probed is not probed again');
});
test('sampler: a roof over the cell centre does not lift a bus on the street', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A roof 22 m up covering the cell's centre; the street 30 m west of it.
const scene = fakeScene((carto) =>
Math.abs(Cesium.Math.toDegrees(carto.longitude) - cell.lon) < 0.0001 ? 142.0 : 121.0);
const bus = { lat: cell.lat, lon: cell.lon - 0.0003 };
sampleMeshFloorCells(scene, [bus], { viewerLat: cell.lat, viewerLon: cell.lon });
assert.equal(cachedGroundFloor(bus.lat, bus.lon), 121.0);
});
test('sampler: a second reading at the same spot keeps the lower one', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon, 131.0), true);
assert.equal(reportValidatedMeshFloorCell(cell.lat + 0.00002, cell.lon, 122.0), true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0);
assert.equal(reportValidatedMeshFloorCell(cell.lat, cell.lon + 0.00002, 140.0), true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 122.0, 'a higher one never raises it');
});
test('sampler: a cell with a reading is probed a few times at most', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
let calls = 0;
const scene = fakeScene(() => {
calls += 1;
return 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 20; i++)
sampleMeshFloorCells(
scene,
[{ lat: cell.lat - 0.0004 + (i % 5) * 0.0002, lon: cell.lon - 0.00045 + Math.floor(i / 5) * 0.0003 }],
opts,
);
assert.equal(calls, 8);
});
test('sampler: a cell with no accepted reading is probed again on every call', async () => {
reset();
const cell = nextSite();
let calls = 0;
const scene = fakeScene(() => {
calls += 1;
return 138.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 3; i++) sampleMeshFloorCells(scene, [cell], opts);
assert.equal(calls, 3, 'no DEM prior yet, so nothing is kept and the cell is retried');
await seedDem(cell, 120.0);
sampleMeshFloorCells(scene, [cell], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), 138.0, 'and lands once the prior is warm');
});
test('sampler: a spot whose reading is refused does not use up the cell', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
// A tower 260 m up where the first vehicle waits; the street elsewhere and
// a deck 9 m up along one line of the cell.
const towerLon = cell.lon + 0.0004;
const deckLat = cell.lat + 0.0003;
const scene = fakeScene((carto) => {
if (Math.abs(Cesium.Math.toDegrees(carto.longitude) - towerLon) < 1e-7) return 380.0;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
for (let i = 0; i < 10; i++) sampleMeshFloorCells(scene, [{ lat: cell.lat, lon: towerLon }], opts);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null, 'the tower is refused every time');
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
sampleMeshFloorCells(scene, [{ lat: deckLat, lon: cell.lon - 0.0003 }], opts);
assert.equal(cachedMeshFloor(deckLat, cell.lon - 0.0003), 129.0, 'the deck is still probed');
});
test('ground floor: a cell keeps at most sixteen readings', async () => {
reset();
const cell = nextSite();
await seedDem(cell, 120.0);
for (let i = 0; i < 40; i++) {
const lat = cell.lat - 0.00045 + (i % 8) * 0.0001;
const lon = cell.lon - 0.00045 + Math.floor(i / 8) * 0.0002;
reportValidatedMeshFloorCell(lat, lon, 121.0 + i);
}
// Readings 0..15 are kept; reading 39 (at the far corner) is not, so that
// spot answers from the nearest kept one.
assert.equal(cachedMeshFloor(cell.lat - 0.00045, cell.lon - 0.00045), 121.0);
assert.equal(cachedMeshFloor(cell.lat + 0.00025, cell.lon + 0.00035), 121.0 + 15);
});
test('sampler: a spot refused while the DEM was cold is probed again once it is warm', async () => {
reset();
const cell = nextSite();
const deckLat = cell.lat + 0.0003;
let calls = 0;
const scene = fakeScene((carto) => {
calls += 1;
return Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0;
});
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), null, 'no prior, nothing kept');
await seedDem(cell, 120.0);
sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(calls, 3);
assert.equal(cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
});
test('sampler: a spot refused while the DEM prior was evicted is probed again', async () => {
const { createGroundFloor } = await import('../services/groundFloor.js');
const { createMeshFloorSampler } = await import('../services/meshFloorSampler.js');
let prior = 120.0;
const terrain = {
cachedRealEllipsoidalGround: () => prior,
resolveEllipsoidalGround: async () => {},
};
const groundFloor = createGroundFloor({ terrain });
const sampler = createMeshFloorSampler({ groundFloor });
const cell = { lat: 41.5, lon: -97.66 };
const deckLat = cell.lat + 0.0003;
const scene = fakeScene((carto) =>
Math.abs(Cesium.Math.toDegrees(carto.latitude) - deckLat) < 1e-7 ? 129.0 : 121.0);
const opts = { viewerLat: cell.lat, viewerLon: cell.lon };
const onDeck = { lat: deckLat, lon: cell.lon };
sampler.sampleMeshFloorCells(scene, [{ lat: cell.lat - 0.0003, lon: cell.lon }], opts);
prior = null; // the cell's DEM entry is evicted; the street reading stays
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 121.0);
prior = 120.0;
sampler.sampleMeshFloorCells(scene, [onDeck], opts);
assert.equal(groundFloor.cachedMeshFloor(onDeck.lat, onDeck.lon), 129.0);
});
+87 -1
View File
@@ -425,7 +425,7 @@ test('a dependency the user adopts stays on when Contacts turns off', async () =
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
"the adopted layer is the user's now; the rest are still released",
);
} finally {
runtime.restore();
@@ -1118,6 +1118,92 @@ test('production military tracked-subject label falls back to registration befor
}
});
test('the tracked airliner’s photo reaches the Contacts panel when it lands after selection', async () => {
const id = 'a1f2c3';
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
const sourceViewer = { camera: { positionCartographic: null }, scene: {} };
const runtime = installAwarenessRuntime();
const restores = [];
const meta = {
callsign: 'AAL100',
altitude: 10_668,
renderAltitudeM: 10_700,
velocity: 250,
true_track: 95,
klass: 'airliner',
onGround: false,
wasAirborne: true,
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
photo: null,
};
try {
_setTrackedFlightRefreshStateForTest({
icao24: id,
entity: null,
billboard: { position, color: Cesium.Color.WHITE, show: true },
billboardCollection: { show: true, remove() {} },
viewer: sourceViewer,
meta,
});
runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` };
replaceMethod(
militaryFlightsLayer,
'getTrackedSubject',
() => null,
restores,
);
replaceMethod(flightsLayer, 'trackById', () => true, restores);
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
assert.equal(runtime.panel().view?.kind, 'results');
assert.equal(runtime.panel().view.photo, null, 'no photo before it lands');
// An unchanged refresh stays quiet.
const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(runtime.panel().publishes, settledPublishes);
// The photo lands while the plane holds still: render-worthy on its own.
meta.photo = {
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
width: 280,
height: 186,
photographer: 'Jane Doe',
link: 'https://www.planespotters.net/photo/1/n100aa',
};
await militaryAwarenessLayer.update();
assert.deepEqual(runtime.panel().view.photo, {
src: 'https://t.plnspttrs.net/1/a1f2c3_280.jpg',
width: 280,
height: 186,
link: 'https://www.planespotters.net/photo/1/n100aa',
credit: 'Photo © Jane Doe',
});
// And it repaints once, not on every refresh.
const shownPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(runtime.panel().publishes, shownPublishes);
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedFlightRefreshStateForTest({
icao24: id,
entity: null,
billboard: { position, show: true },
billboardCollection: { show: false, remove() {} },
viewer: sourceViewer,
meta: {},
tracked: false,
});
runtime.restore();
}
});
test('a cached Context subject re-reads its label when enrichment lands after selection', async () => {
// The subject snapshot is captured once at selection. Selecting a
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
+181 -8
View File
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
/** @constant {number} Dead reckoning never moves a contact faster than this
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
* cannot fly a coasting plane hundreds of kilometres. */
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
export const STALE_COAST_MAX_SEC = 300;
/** @constant {number} A report this close to a known ground height holds
* instead of coasting. */
export const STALE_HOLD_MAX_HEIGHT_M = 150;
/**
* Whether a contact whose reports have stopped holds at its last reported
* position instead of coasting: it reported on the ground, or a known ground
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
* taking off). A plane that low has no evidence of being anywhere but where
* it was last heard. Where the ground is unknown, barometric altitude is not
* read as height above it; the contact coasts under the stale cap.
* @param {object} report
* @param {boolean} [report.onGround]
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
* @returns {boolean}
*/
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
if (onGround === true) return true;
return (
Number.isFinite(heightM) &&
Number.isFinite(groundM) &&
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
);
}
/**
* The hold-or-coast decision for a report, made once and kept on the report,
* so a ground floor that resolves while the contact coasts cannot flip it and
* snap the icon back. Height and position come from the report itself.
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
* fix; `staleHolds` is stored on it.
* @param {object} args
* @param {boolean} [args.onGround] - The contact's ground flag.
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
* @returns {boolean}
*/
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
const carto = Cesium.Cartographic.fromCartesian(
fix.position,
Cesium.Ellipsoid.WGS84,
_holdCarto,
);
const groundM =
carto && typeof cachedGroundFloor === 'function'
? cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
)
: null;
fix.staleHolds = staleHoldsAtLastReport({
onGround,
heightM: carto?.height,
groundM,
});
return fix.staleHolds;
}
const _holdCarto = new Cesium.Cartographic();
/**
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
* after it, counting any coast a synthetic fix already took since the real
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
* never coasts further than one report would.
* @param {object} args
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
* @param {number} args.elapsedSec - Display time past `newest`.
* @param {number} args.coastLimitSec - Source limit past `newest`.
* @param {boolean} args.holds
* @returns {number}
*/
export function staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}) {
const alreadySec = Number.isFinite(newest?.reportEpochMs)
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
: 0;
return Math.max(
0,
staleCoastSeconds({
elapsedSec: elapsedSec + alreadySec,
coastLimitSec: coastLimitSec + alreadySec,
holds,
}) - alreadySec,
);
}
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
export const COAST_FLOOR_LIFT_M = 1.5;
/**
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
* ground is known; a coast runs level from the report and terrain may rise
* under it.
* @param {Cesium.Cartesian3} position - Mutated in place.
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
* @returns {Cesium.Cartesian3}
*/
export function floorCoastedPosition(position, cachedGroundFloor) {
if (typeof cachedGroundFloor !== 'function') return position;
const carto = Cesium.Cartographic.fromCartesian(
position,
Cesium.Ellipsoid.WGS84,
_coastCarto,
);
if (!carto) return position;
const ground = cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
return position;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
ground + COAST_FLOOR_LIFT_M,
Cesium.Ellipsoid.WGS84,
position,
);
}
const _coastCarto = new Cesium.Cartographic();
/**
* Seconds to coast past the last report: the elapsed time, bounded by the
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
* holds.
* @param {object} args
* @param {number} args.elapsedSec - Display time past the last report.
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
* @returns {number}
*/
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
if (holds || !(elapsedSec > 0)) return 0;
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
}
/**
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* kinematics. This prevents a late course change from reprojecting the entire
* stale interval and snapping the rendered contact by kilometres.
*
* The same stale rules as the display apply: no fix for a contact that holds
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
* most STALE_COAST_MAX_SEC counted from the real report
* (`reportEpochMs` carries it along a chain), and never below known ground.
* The result is a motion fix only, never a report for the trail.
*
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
* velocity?: number, track?: number}} newest Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
* Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
* cachedGroundFloor?: Function}} next
* New kinematics and the wall/source epoch at which they become authoritative.
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
* velocity: number, track: number}|null} Forward synthetic fix, or null.
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
* Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
if (newest.staleHolds === true) return null;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
const dtSec = staleCoastSecondsFrom({
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
elapsedSec: (next.epochMs - startEpochMs) / 1000,
coastLimitSec: Infinity,
holds: false,
});
arcOffsetEnu(
previousVelocity,
previousTrack,
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
Cesium.Ellipsoid.WGS84,
_forwardFixEnu,
);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
const position = floorCoastedPosition(
Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
),
next.cachedGroundFloor,
);
return {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: startEpochMs,
staleHolds: newest.staleHolds,
position,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
+121
View File
@@ -0,0 +1,121 @@
/**
* @module planespottersPhoto
* @description Reads a Planespotters photo answer into the one credited
* thumbnail every surface shows. Planespotters lets the thumbnail be hotlinked
* on the condition that the photographer is credited next to it and the image
* links back to the photo's page, so a photo without a photographer, or whose
* image or page is not on Planespotters' own hosts, is "no photo": nothing
* arbitrary is ever shown or linked.
*
* Accepts the upstream answer (`GET https://api.planespotters.net/pub/photos/hex/{hex}`,
* `{photos: [...]}`), the cached server shape
* (`{found, thumbnail_large?, thumbnail: {src, width, height}, photographer,
* link}`), and an
* already-read photo (`{found, src, width, height, photographer, link}`).
* Pure: no fetch, no DOM.
*/
/** The image host: Planespotters' thumbnail CDN and its subdomains. */
const IMAGE_HOST = 'plnspttrs.net';
/** The only page host a photo may link to. */
const PAGE_HOST = 'www.planespotters.net';
const NO_PHOTO = Object.freeze({ found: false });
const isObject = (value) => Boolean(value) && typeof value === 'object';
/** The parsed `https:` URL, or null for anything else or with userinfo/port. */
function httpsUrl(value) {
if (typeof value !== 'string' || !value) return null;
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password || url.port)
return null;
return url;
}
function imageSrc(value) {
const url = httpsUrl(value);
return url &&
(url.hostname === IMAGE_HOST || url.hostname.endsWith(`.${IMAGE_HOST}`))
? url.href
: null;
}
function pageLink(value) {
const url = httpsUrl(value);
return url && url.hostname === PAGE_HOST ? url.href : null;
}
function dimension(value) {
const number = Number(value);
return value != null && Number.isFinite(number) && number > 0
? Math.round(number)
: null;
}
/** One candidate `{src, width, height, photographer, link}`, checked. */
function checked({ src, width, height, photographer, link }) {
const credit = typeof photographer === 'string' ? photographer.trim() : '';
const image = imageSrc(src);
const page = pageLink(link);
if (!credit || !image || !page) return null;
return {
found: true,
src: image,
width: dimension(width),
height: dimension(height),
photographer: credit,
link: page,
};
}
/** The upstream photo's candidates, large thumbnail first. */
function upstreamCandidates(photo) {
if (!photo || typeof photo !== 'object') return [];
return [photo.thumbnail_large, photo.thumbnail]
.filter((thumb) => thumb && typeof thumb === 'object')
.map((thumb) => ({
src: thumb.src,
width: thumb.size?.width,
height: thumb.size?.height,
photographer: photo.photographer,
link: photo.link,
}));
}
/**
* Read a Planespotters answer into the first photo that passes the checks.
* @param {*} payload Upstream, cached-server, or already-read photo.
* @returns {{found: true, src: string, width: ?number, height: ?number,
* photographer: string, link: string} | {found: false}}
*/
export function parsePlanespottersPhoto(payload) {
if (!payload || typeof payload !== 'object') return NO_PHOTO;
let candidates;
if (Array.isArray(payload.photos)) {
candidates = payload.photos.flatMap(upstreamCandidates);
} else if (payload.found !== true) {
return NO_PHOTO;
} else if (isObject(payload.thumbnail) || isObject(payload.thumbnail_large)) {
// Large first, as for the upstream answer; each must pass the checks.
candidates = [payload.thumbnail_large, payload.thumbnail]
.filter(isObject)
.map((thumb) => ({
...thumb,
photographer: payload.photographer,
link: payload.link,
}));
} else {
candidates = [payload];
}
for (const candidate of candidates) {
const photo = checked(candidate);
if (photo) return photo;
}
return NO_PHOTO;
}
+200
View File
@@ -0,0 +1,200 @@
// src/data/planespottersPhoto.test.mjs — the Planespotters photo answer is read
// into one credited thumbnail, and nothing outside Planespotters' own hosts is
// ever shown or linked.
import test from 'node:test';
import assert from 'node:assert/strict';
import { parsePlanespottersPhoto } from './planespottersPhoto.js';
const UPSTREAM = Object.freeze({
photos: [
{
id: '1745410',
thumbnail: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
size: { width: 200, height: 133 },
},
thumbnail_large: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
size: { width: 420, height: 280 },
},
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
photographer: 'Jan Seler',
},
],
});
const PHOTO = Object.freeze({
found: true,
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
width: 420,
height: 280,
photographer: 'Jan Seler',
link: 'https://www.planespotters.net/photo/1745410/n160an-american-airlines-airbus-a321-231-wl?utm_source=api',
});
const photo = (overrides) => ({
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
});
test('the upstream answer maps to the large thumbnail, its size, credit and link', () => {
assert.deepEqual(parsePlanespottersPhoto(UPSTREAM), PHOTO);
});
test('the small thumbnail stands in when the large one is missing', () => {
assert.deepEqual(parsePlanespottersPhoto(photo({ thumbnail_large: null })), {
...PHOTO,
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
});
});
test('a server’s cached shape reads the same way', () => {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: { src: PHOTO.src, width: 420, height: 280 },
photographer: 'Jan Seler',
link: PHOTO.link,
}),
PHOTO,
);
assert.deepEqual(parsePlanespottersPhoto({ found: false }), {
found: false,
});
});
test('a server’s cached shape prefers its large thumbnail', () => {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
},
thumbnail_large: { src: PHOTO.src, width: 420, height: 280 },
photographer: 'Jan Seler',
link: PHOTO.link,
}),
PHOTO,
);
});
test('a server’s cached shape falls back to its small thumbnail when the large one fails the checks', () => {
const small = {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_t.jpg',
width: 200,
height: 133,
};
for (const thumbnail_large of [
{ src: 'https://evil.example/a.jpg', width: 420, height: 280 },
null,
]) {
assert.deepEqual(
parsePlanespottersPhoto({
found: true,
thumbnail: small,
thumbnail_large,
photographer: 'Jan Seler',
link: PHOTO.link,
}),
{ ...PHOTO, ...small },
JSON.stringify(thumbnail_large),
);
}
});
test('no photo, junk, and missing fields are "no photo"', () => {
for (const payload of [
{ photos: [] },
{},
null,
undefined,
'nope',
{ photos: 'nope' },
{ photos: [null] },
photo({ photographer: '' }),
photo({ photographer: ' ' }),
photo({ photographer: 42 }),
photo({ link: '' }),
photo({ thumbnail: null, thumbnail_large: null }),
]) {
assert.deepEqual(
parsePlanespottersPhoto(payload),
{ found: false },
JSON.stringify(payload),
);
}
});
test('a src outside https *.plnspttrs.net is dropped', () => {
for (const src of [
'http://t.plnspttrs.net/a.jpg',
'https://plnspttrs.net.evil.example/a.jpg',
'https://evil.example/t.plnspttrs.net/a.jpg',
'https://t.plnspttrs.net.evil.example/a.jpg',
'https://[email protected]/a.jpg',
'https://t.plnspttrs.net:8443/a.jpg',
'javascript:alert(1)',
'//t.plnspttrs.net/a.jpg',
'not a url',
]) {
assert.deepEqual(
parsePlanespottersPhoto(
photo({
thumbnail_large: { src, size: { width: 420, height: 280 } },
thumbnail: { src, size: { width: 200, height: 133 } },
}),
),
{ found: false },
src,
);
}
});
test('a link anywhere but https://www.planespotters.net is dropped', () => {
for (const link of [
'http://www.planespotters.net/photo/1',
'https://planespotters.net/photo/1',
'https://www.planespotters.net.evil.example/photo/1',
'https://evil.example/?u=https://www.planespotters.net/',
'https://user:[email protected]/photo/1',
'https://www.planespotters.net:444/photo/1',
'javascript:alert(1)',
]) {
assert.deepEqual(
parsePlanespottersPhoto(photo({ link })),
{ found: false },
link,
);
}
});
test('the first photo that passes the checks is the one shown', () => {
const second = {
...UPSTREAM.photos[0],
photographer: 'Second Spotter',
};
assert.deepEqual(
parsePlanespottersPhoto({
photos: [
{ ...UPSTREAM.photos[0], link: 'https://evil.example/' },
second,
],
}),
{ ...PHOTO, photographer: 'Second Spotter' },
);
});
test('a missing size is null and the credit is trimmed', () => {
assert.deepEqual(
parsePlanespottersPhoto(
photo({
photographer: ' Jan Seler ',
thumbnail_large: { src: PHOTO.src },
}),
),
{ ...PHOTO, width: null, height: null },
);
});
+10 -1
View File
@@ -46,7 +46,13 @@ function alongTrackKm(position, origin, destination) {
}
const NEAR_ENDPOINT_KM = 130;
/** Least allowed distance off the great circle, and how far past either
* endpoint the plane may be along it. */
const CROSS_TRACK_KM = 200;
/** On a long route the allowed distance off the great circle grows to this
* share of its length: airways and winds take a coast-to-coast flight several
* hundred kilometres off it (JFK–SFO: 623 km). */
const CROSS_TRACK_ROUTE_FRACTION = 0.15;
const LOW_ALT_M = 3700; // ~12 000 ft
const VERT_TREND_MPS = 2; // ~400 fpm
const LOCAL_AIRPORT_KM = 150;
@@ -90,6 +96,9 @@ export function routePlausible({
// existing loose check when the segment cannot be judged reliably.
const segmentKnown =
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
const corridorKm = segmentKnown
? Math.max(CROSS_TRACK_KM, CROSS_TRACK_ROUTE_FRACTION * routeKm)
: CROSS_TRACK_KM;
geomOk =
Math.abs(
crossTrackKm(
@@ -100,7 +109,7 @@ export function routePlausible({
destination.lat,
destination.lon,
),
) < CROSS_TRACK_KM &&
) < corridorKm &&
(!segmentKnown ||
(segmentAlongKm >= -CROSS_TRACK_KM &&
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
+18
View File
@@ -27,6 +27,24 @@ test('plane in London with an SFO→LAX route: implausible', () => {
}), false);
});
test('a long route allows a wider corridor; a short one does not', () => {
// JFK→SFO over Kansas, 384 km south of the great circle.
assert.equal(routePlausible({
latDeg: 38.21, lonDeg: -99.69, altitudeM: 10370, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), true);
// 516 km off the 543 km SFO→LAX route is still the wrong route.
assert.equal(routePlausible({
latDeg: 37.9, lonDeg: -115.0, altitudeM: 10000, verticalRateMps: 0,
origin: SFO, destination: LAX,
}), false);
// 960 km off, past 15 % of the 4,152 km JFK→SFO route, is still wrong.
assert.equal(routePlausible({
latDeg: 33.0, lonDeg: -99.69, altitudeM: 10000, verticalRateMps: 0,
origin: JFK, destination: SFO,
}), false);
});
test('vertical trend: low climbing plane near SFO — origin must be local', () => {
const nearSfo = { latDeg: 37.7, lonDeg: -122.4, altitudeM: 2500 };
// JFK→SFO passes geometry (near destination), but a CLIMBING plane here just
+4 -1
View File
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
].map((name) => [name, {}]),
);
services.aircraftPresentation = { isTr3b: () => false };
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
const parts = {
queries: { _contactLabel: (_, info) => info.callsign },
motion: { _signalLost: () => null },
};
const tracking = createTracking({ flightState, services, parts });
for (const [position, expected] of [
[beyond, false],
+53
View File
@@ -0,0 +1,53 @@
/**
* @file Upstream-request accounting shared by the keyed tile proxies.
*
* A soft cap per UTC period, persisted as `{date, count}` so it survives a
* restart. Nothing here is provider-specific: the caller picks the period by
* the key it passes, and decides what happens over the cap.
*
* Zero dependencies and Cesium-free, so providers and browser helpers can
* import it and node:test can exercise it directly.
*
* @module data/tileBudget
*/
/**
* UTC calendar-day key for budget bucketing.
*
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
* @returns {string} 'YYYY-MM-DD' in UTC.
*/
export function utcDayKey(epochMs = Date.now()) {
return new Date(epochMs).toISOString().slice(0, 10);
}
/**
* Normalize a persisted budget state against the current period key.
* Rolls the counter to zero when the period changes; replaces missing/corrupt
* state. Returns the SAME object when it is already valid for `periodKey`
* (cheap to call on every request).
*
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
* @param {string} periodKey - Current period key, e.g. from `utcDayKey`.
* @returns {{date:string, count:number}} Valid state for `periodKey`.
*/
export function normalizeBudget(state, periodKey) {
const valid =
Boolean(state) &&
state.date === periodKey &&
Number.isFinite(state.count) &&
state.count >= 0;
return valid ? state : { date: periodKey, count: 0 };
}
/**
* Whether the soft cap for the current period has been reached.
*
* @param {{count:number}} state - Normalized budget state.
* @param {number} limit - Budget per period; non-positive/invalid never blocks.
* @returns {boolean} True when `count >= limit`.
*/
export function isOverBudget(state, limit) {
if (!Number.isFinite(limit) || limit <= 0) return false;
return state.count >= limit;
}
+10
View File
@@ -0,0 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isOverBudget, normalizeBudget, utcDayKey } from './tileBudget.js';
import * as tomtom from './tomtomTiles.js';
test('tile budget helpers live in one module and TomTom re-exports them', () => {
assert.equal(tomtom.utcDayKey, utcDayKey);
assert.equal(tomtom.normalizeBudget, normalizeBudget);
assert.equal(tomtom.isOverBudget, isOverBudget);
});
+6 -40
View File
@@ -1,9 +1,10 @@
/**
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
* @file Pure tile math for the TomTom traffic-flow proxy; budget helpers are
* re-exported from `./tileBudget.js`.
*
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
* cover the current traffic fetch bounds). No external dependencies, Cesium-free, so
* both sides can unit-test against it with node:test.
*
* Slippy scheme: standard Web Mercator XYZ, y grows southward
@@ -131,42 +132,7 @@ export function tilesForBounds(bounds, zoom = 12, { maxTiles = 64 } = {}) {
// ─── Daily budget accounting ───────────────────────────────
/**
* UTC calendar-day key for budget bucketing.
*
* @param {number} [epochMs=Date.now()] - Timestamp in ms.
* @returns {string} 'YYYY-MM-DD' in UTC.
* Re-exported from `./tileBudget.js`, which keyed tile proxies share. The
* traffic provider and its tests keep one import for tile math and budget.
*/
export function utcDayKey(epochMs = Date.now()) {
return new Date(epochMs).toISOString().slice(0, 10);
}
/**
* Normalize a persisted budget state against today's UTC day key.
* Rolls the counter to zero on day change; replaces missing/corrupt state.
* Returns the SAME object when it is already valid for `dayKey` (cheap to
* call on every request).
*
* @param {{date:string, count:number}|null|undefined} state - Persisted state.
* @param {string} dayKey - Today's UTC day key (from `utcDayKey`).
* @returns {{date:string, count:number}} Valid state for `dayKey`.
*/
export function normalizeBudget(state, dayKey) {
const valid =
Boolean(state) &&
state.date === dayKey &&
Number.isFinite(state.count) &&
state.count >= 0;
return valid ? state : { date: dayKey, count: 0 };
}
/**
* Whether the daily soft cap has been reached.
*
* @param {{count:number}} state - Normalized budget state.
* @param {number} limit - Daily tile budget; non-positive/invalid never blocks.
* @returns {boolean} True when `count >= limit`.
*/
export function isOverBudget(state, limit) {
if (!Number.isFinite(limit) || limit <= 0) return false;
return state.count >= limit;
}
export { utcDayKey, normalizeBudget, isOverBudget } from './tileBudget.js';
+607
View File
@@ -0,0 +1,607 @@
/**
* @module trailContinuity
* @description Where a tracked aircraft's trail lifts the pen.
*
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
* was drawn between them. Between two consecutive fixes that are not one path, a new
* segment starts and nothing is drawn between them: no dashes, no arcs.
* Nothing is dropped except the oldest fixes past the layer's point limit.
*
* Two points are not one path when time does not advance, when the gap is
* longer than 48 minutes (a coverage hole or a separate flight), when the
* implied great-circle speed is over 1,500 kt, or when either point has no
* reported altitude. A parked aircraft is the exception: a pair under three
* hours apart within 0.1 nm stays one path.
*
* Civil and military flight tracking share these rules.
*/
import * as Cesium from 'cesium';
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
const METRES_PER_NM = 1852;
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/**
* Whether two consecutive trail points belong to different paths. Time that
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
* parked aircraft: a pair under three hours apart within 0.1 nm.
* @param {number} deltaMs - Later point's time minus the earlier one's.
* @param {number} distanceM - Great-circle distance between the points.
* @returns {boolean}
*/
export function trailPairBreaks(deltaMs, distanceM) {
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
const distanceNm = distanceM / METRES_PER_NM;
if (
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
)
return false;
return (
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
);
}
/**
* Whether the head segment may join the last body point to the aircraft:
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
* @param {number} deltaMs - Current time minus the body point's time.
* @param {number} distanceM - Distance from the body point to the aircraft.
* @returns {boolean}
*/
export function trailHeadJoins(deltaMs, distanceM) {
return (
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
);
}
/**
* Great-circle distance between two trail positions, in metres.
* @param {Cesium.Cartesian3} a
* @param {Cesium.Cartesian3} b
* @returns {number}
*/
export function trailDistanceM(a, b) {
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
}
/**
* Whether the pen lifts between two consecutive fixes.
* @param {object|undefined} prev - Earlier fix, if any.
* @param {object} next - Later fix.
* @returns {boolean}
*/
export function trailFixBreaks(prev, next) {
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
// A recorded point is where the icon was drawn; the speed between it and a
// report it was eased onto is not a flight speed, so only the gap counts.
if (prev.drawn || next.drawn)
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
return trailPairBreaks(
next.timeMs - prev.timeMs,
trailDistanceM(prev.position, next.position),
);
}
/**
* Build backfill fixes from chronological waypoints. Every waypoint with a
* position is kept. A waypoint without one (no altitude and no known ground)
* is skipped, and the line still breaks there.
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
* line breaks after the last one because trailing waypoints were skipped.
*/
export function trailBackfillFixes(waypoints) {
const fixes = [];
let skipped = false;
for (const { position, timeMs, nullAltitude } of waypoints) {
if (!position) {
skipped = true;
continue;
}
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
skipped = false;
fixes.push(fix);
}
return { fixes, breakAfter: skipped };
}
/**
* Put backfill fixes ahead of the live trail. The join follows the same
* rule, so a distant history tail never connects to the first live fix.
* Past `maxPoints` the oldest recorded points go first, then the oldest
* reports.
* @param {{fixes: object[], breakAfter: boolean}} backfill
* @param {object[]} live - Live fixes; the first one's break is recomputed.
* @param {number} maxPoints - Point limit across all segments.
* @returns {object[]} The joined trail.
*/
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
if (!fixes.length) return live;
if (live.length)
live[0].breakBefore =
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
return keepTrailLimit(fixes.concat(live), maxPoints);
}
/**
* How many fixes the drawn body holds: every fix at or before the display
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
* the delayed display) are reached by the head segment instead, so the trail
* never runs past the aircraft, and once the display passes the newest
* report the body ends there.
* @param {object[]} fixes
* @param {number} displayMs - Time the aircraft is drawn at.
* @returns {number}
*/
export function trailBodyCount(fixes, displayMs) {
let count = fixes.length;
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
return count;
}
/**
* Leave out the newest recorded points that lie inside the aircraft's 3D
* model, so the body never runs through the fuselage; the head then runs
* from the newest point outside the model to the plane. Reports are kept.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
* when no model is the visual.
* @param {number|null} envelopeM - The model's radius (m).
* @returns {number}
*/
export function trailBodyCountOutsideModel(
fixes,
count,
modelCenter,
envelopeM,
) {
if (!modelCenter || !(envelopeM > 0)) return count;
while (
count > 0 &&
fixes[count - 1].drawn &&
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
envelopeM
)
count -= 1;
return count;
}
/**
* The drawn trail body, split at breaks: the first `count` fixes.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @returns {Cesium.Cartesian3[][]}
*/
export function trailBodySegments(fixes, count) {
const segments = [];
for (let i = 0; i < count; i++) {
const fix = fixes[i];
// A live report with no altitude keeps no position; it only breaks the line.
if (!fix.position) continue;
if (fix.breakBefore || !segments.length) segments.push([]);
segments[segments.length - 1].push(fix.position);
}
return segments;
}
/**
* Draw body segments. A trail without breaks support draws only the newest
* segment, never a line across a break.
* @param {{setSegments?: Function, setPositions: Function}} trail
* @param {Cesium.Cartesian3[][]} segments
*/
export function drawTrailSegments(trail, segments) {
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
else trail.setPositions(segments[segments.length - 1] ?? []);
}
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
const TRAIL_HEAD_MIN_M = 0.5;
/**
* The body fix the head segment starts from, or null when the head is not
* drawn: no body yet, the aircraft holding at that fix, a body point more
* than 150 km from the aircraft, or one too long ago or too far away to be
* one path.
* @param {object[]} fixes
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
* @param {number} nowMs - Current time.
* @param {number} count - Body fixes, from `trailBodyCount`.
* @returns {object|null}
*/
export function trailHeadStartFix(fixes, head, nowMs, count) {
if (count < 1 || !head) return null;
const last = fixes[count - 1];
if (!last.position) return null;
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
return null;
return trailHeadJoins(
nowMs - last.timeMs,
trailDistanceM(last.position, head),
)
? last
: null;
}
/**
* Epoch ms of a position-history fix (`{epochMs?, time}`).
* @param {object|undefined} fix
* @returns {number} NaN when unknown.
*/
export function historyFixEpochMs(fix) {
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
}
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
export const TRAIL_RECORD_STEP_MS = 1_000;
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
export const TRAIL_RECORD_STEP_M = 150;
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
export const TRAIL_THIN_TOLERANCE_M = 30;
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
const TRAIL_THIN_MAX_ABSORBED = 512;
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
export const TRAIL_SPUR_TURN_DEG = 120;
/** @constant {number} ...over a step shorter than this (m). */
export const TRAIL_SPUR_MAX_STEP_M = 500;
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
const TRAIL_RECORD_MIN_STEP_M = 1;
const _u = new Cesium.Cartesian3();
const _v = new Cesium.Cartesian3();
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
function turnCos(a, b, c) {
Cesium.Cartesian3.subtract(b, a, _u);
Cesium.Cartesian3.subtract(c, b, _v);
const lu = Cesium.Cartesian3.magnitude(_u);
const lv = Cesium.Cartesian3.magnitude(_v);
if (!(lu > 0 && lv > 0)) return NaN;
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
}
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
/** Whether entry `i` and its neighbours are one drawn piece of line. */
function joinedAt(fixes, i) {
const a = fixes[i - 1];
const b = fixes[i];
const c = fixes[i + 1];
return (
a?.position &&
b?.position &&
c?.position &&
!b.breakBefore &&
!c.breakBefore
);
}
/** Whether the line turns back on itself at entry `i` over a short step. */
function spurAt(fixes, i) {
if (!joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const b = fixes[i].position;
const c = fixes[i + 1].position;
if (
Math.min(
Cesium.Cartesian3.distance(a, b),
Cesium.Cartesian3.distance(b, c),
) > TRAIL_SPUR_MAX_STEP_M
)
return false;
return turnCos(a, b, c) < SPUR_COS;
}
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
function removeAt(fixes, i) {
fixes.splice(i, 1);
if (i < fixes.length)
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
}
/**
* Keep the point limit: drop the oldest recorded point first, and the oldest
* report only when no recorded point is left.
* @param {object[]} fixes - The trail, mutated.
* @param {number} maxPoints
* @returns {object[]} `fixes`.
*/
function keepTrailLimit(fixes, maxPoints) {
let excess = fixes.length - maxPoints;
if (excess <= 0) return fixes;
for (let i = 0; i < fixes.length && excess > 0;) {
if (fixes[i].drawn) {
removeAt(fixes, i);
excess -= 1;
} else i += 1;
}
if (excess > 0) fixes.splice(0, excess);
return fixes;
}
const _ab = new Cesium.Cartesian3();
const _ap = new Cesium.Cartesian3();
const _off = new Cesium.Cartesian3();
const _up = new Cesium.Cartesian3();
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
function nearSegment(p, a, b) {
Cesium.Cartesian3.subtract(b, a, _ab);
Cesium.Cartesian3.subtract(p, a, _ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
: 0;
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
Cesium.Cartesian3.subtract(_ap, _off, _off);
Cesium.Cartesian3.normalize(p, _up);
const vertical = Cesium.Cartesian3.dot(_off, _up);
const across = Math.sqrt(
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
);
return (
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
across <= TRAIL_THIN_TOLERANCE_M
);
}
/**
* Drop recorded point `i` when the line without it stays within the
* tolerance of every position it stood for, and the straight piece left is
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
* the next entry. Reports are never dropped.
* @returns {boolean} Whether it was dropped.
*/
function thinAt(fixes, i) {
const entry = fixes[i];
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const next = fixes[i + 1];
if (next.sealed) return false;
const b = next.position;
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
const stood = entry.absorbed ?? [];
const nextStood = next.absorbed ?? [];
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
return false;
if (!nearSegment(entry.position, a, b)) return false;
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
next.absorbed = stood.concat(
[{ position: entry.position, timeMs: entry.timeMs }],
nextStood,
);
removeAt(fixes, i);
return true;
}
/**
* Thin the recorded points either side of entry `i`, reaching past a
* neighbouring report: the point recorded just before a report is only
* judged once the line goes on beyond it.
*/
function thinAround(fixes, i) {
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
thinAt(fixes, i + 2);
if (thinAt(fixes, i - 1)) i -= 1;
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
}
/**
* Drop recorded points that make a spur next to entry `i`. Reports are never
* dropped; at a report the recorded neighbour is dropped instead.
* @returns {number} The index entry `i` ends up at.
*/
function removeSpursAround(fixes, i) {
for (let guard = 0; guard < 64; guard++) {
let changed = false;
for (const j of [i - 1, i, i + 1]) {
if (!spurAt(fixes, j)) continue;
let drop = -1;
if (fixes[j].drawn) drop = j;
else if (fixes[j + 1]?.drawn) drop = j + 1;
else if (fixes[j - 1]?.drawn) drop = j - 1;
if (drop < 0) continue;
removeAt(fixes, drop);
if (drop < i) i -= 1;
changed = true;
break;
}
if (!changed) break;
}
return i;
}
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
function insertionIndex(fixes, timeMs) {
let i = fixes.length;
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
return i;
}
/**
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
* points either side and keep the limit.
*/
function insertAt(fixes, i, entry, maxPoints) {
fixes.splice(i, 0, entry);
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
const after = fixes[i + 1];
if (after) {
after.breakBefore = trailFixBreaks(entry, after);
// Positions the next entry stood for that come before this one are now
// on this entry's side.
if (after.absorbed?.length) {
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
if (mine.length) {
entry.absorbed = mine;
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
}
}
}
i = removeSpursAround(fixes, i);
thinAround(fixes, i);
keepTrailLimit(fixes, maxPoints);
}
/** Index of the newest report before entry `i`, or -1. */
function reportBefore(fixes, i) {
let j = i - 1;
while (j >= 0 && fixes[j].drawn) j -= 1;
return j;
}
/** Index of the oldest report at or after `i`, or `fixes.length`. */
function reportFrom(fixes, i) {
let j = i;
while (j < fixes.length && fixes[j].drawn) j += 1;
return j;
}
/**
* Put a report into the trail in time order. Reports are the record: they
* are kept as anchors among the recorded points.
*
* - A report the display has already passed replaces the guess drawn after
* it: the recorded points timed after it go, and nothing is recorded until
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
* line reads arc, report, corrected path.
* - Recorded points never join two reports that break under
* `trailPairBreaks` (too long or too fast apart to be one flight): the
* points between them go and the line breaks.
* - A live report with no altitude keeps no position and only breaks the
* line.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned position.
* @param {number} timeMs - The report's epoch ms.
* @param {number} maxPoints - Point limit across all segments.
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
* `displayMs`: the time the aircraft is drawn at as the report arrives;
* `settleMs`: how long the icon takes to ease onto a correction.
* @returns {object[]} `fixes`.
*/
export function insertTrailFix(
fixes,
position,
timeMs,
maxPoints,
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
) {
const entry = {
position: nullAltitude ? null : position,
timeMs,
nullAltitude,
drawn: false,
breakBefore: false,
};
let i = insertionIndex(fixes, timeMs);
// The guess drawn after a report the display already passed.
fixes.splice(i, reportFrom(fixes, i) - i);
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
// Recorded points across a pair of reports that are not one flight.
const before = reportBefore(fixes, i);
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
fixes.splice(before + 1, i - before - 1);
i = before + 1;
}
insertAt(fixes, i, entry, maxPoints);
return fixes;
}
/**
* Record where the tracked aircraft was drawn at a display time. Between
* reports this recorded path is the trail, so the line follows every turn
* the icon made instead of a straight chord between two reports. Points are
* thinned by shape: one stays only where dropping it would move the line by
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
* while the aircraft holds.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned drawn position.
* @param {number} timeMs - Display time it was drawn for.
* @param {number} maxPoints - Point limit across all segments.
* @returns {boolean} Whether the trail changed.
*/
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
const i = insertionIndex(fixes, timeMs);
const prev = fixes[i - 1];
if (
prev?.position &&
Cesium.Cartesian3.distance(prev.position, position) <
TRAIL_RECORD_MIN_STEP_M
)
return false;
const entry = {
position,
timeMs,
nullAltitude: false,
drawn: true,
breakBefore: false,
};
insertAt(fixes, i, entry, maxPoints);
// Entries before the second report behind this point can never be thinned
// again, so they let go of the positions they stood for.
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
return true;
}
/** Release the absorbed positions of entry `i` and everything before it. */
function sealThrough(fixes, i) {
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
fixes[j].sealed = true;
delete fixes[j].absorbed;
}
}
/**
* Whether the icon's drawn position must not be recorded at a display time:
* a report on either side of it has no altitude (the icon is drawn at an
* invented height), the reports on either side are not one flight
* (`trailPairBreaks`), or the icon is still easing onto a report that
* arrived after the display had passed it.
* @param {object[]} fixes
* @param {number} displayMs
* @returns {boolean}
*/
export function trailRecordingBlocked(fixes, displayMs) {
const i = insertionIndex(fixes, displayMs);
const before = fixes[reportBefore(fixes, i)];
const after = fixes[reportFrom(fixes, i)];
if (before?.nullAltitude || after?.nullAltitude) return true;
if (before?.settleUntilMs > displayMs) return true;
return !!(before && after && trailFixBreaks(before, after));
}
+269 -58
View File
@@ -14,6 +14,18 @@
* the whole history renders at one readable alpha (dimmer where it passes
* behind/below geometry).
*
* A trail with breaks draws each segment separately and nothing between
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
* adjacent chunks share their boundary point, so the line has no seam. A
* chunk that can no longer change (every chunk except the newest one of the
* newest segment) is FROZEN: an entity with constant positions, built once as
* static geometry, so it costs nothing per frame. Only the newest chunk stays
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
* identified by its position objects, so a chunk survives every update that
* keeps those points: appends, a backfill prepended ahead of it, or the
* oldest points dropping off the front (which rebuilds only the first chunk).
*
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
* assigning a fresh positions array per update is cheap. `allowPicking` has
* no entity equivalent; the polyline entity is excluded from clicks by never
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
const TRAIL_OCCLUDED_ALPHA = 0.4;
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
const NO_POSITIONS = Object.freeze([]);
/** @constant {number} Points per chunk; adjacent chunks share one point. */
export const CHUNK_POINTS = 128;
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
* static geometry before it is dropped anyway. */
const STAND_IN_MAX_FRAMES = 600;
/**
* Create an always-visible polyline trail bound to a viewer.
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
* @param {object} options - Trail options.
* @param {string} options.color - CSS color string for the trail hue.
* @param {number} [options.width=2.5] - Polyline width in pixels.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry, setVisible temporarily
* hides it without discarding history, clear empties it, and destroy removes
* the entity permanently.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry with one line,
* setSegments with one line per segment, setVisible temporarily hides it
* without discarding history, clear empties it, and destroy removes the
* entities permanently.
*/
export function createTrail(viewer, { color, width = 2.5 }) {
const baseColor = Cesium.Color.fromCssColorString(color);
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
let current = [];
const material = baseColor.withAlpha(TRAIL_ALPHA);
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
let destroyed = false;
let visible = true;
/** @type {Cesium.Entity|null} */
let entity = null;
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
let frozen = [];
/** Newest chunk of the newest segment: the only part that changes. */
let tail = NO_POSITIONS;
/** What the dynamic polylines draw: the tail, then frozen chunks whose
* static geometry is not in the scene yet. */
let dynamicRuns = [];
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
const dynamicEntities = [];
let removeReadyListener = null;
const scratchSphere = new Cesium.BoundingSphere();
function ensureEntity() {
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
entity = viewer.entities.add({
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
function polyline(positions) {
return {
positions,
width,
material,
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial,
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
};
}
function addEntity(positions) {
return viewer.entities.add({
id: `gev-trail:${++_trailSeq}`,
show: visible,
polyline: {
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
positions: new Cesium.CallbackProperty(() => current, false),
width,
material: baseColor.withAlpha(TRAIL_ALPHA),
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
},
polyline: polyline(positions),
});
}
function removeEntity(entity) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
}
function syncDynamic() {
dynamicRuns = [];
if (tail.length >= 2) dynamicRuns.push(tail);
for (const chunk of frozen)
if (chunk.standIn) dynamicRuns.push(chunk.positions);
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
const index = dynamicEntities.length;
dynamicEntities.push(
addEntity(
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
new Cesium.CallbackProperty(
() => dynamicRuns[index] ?? NO_POSITIONS,
false,
),
),
);
}
}
/** Whether a frozen chunk's static geometry is drawn in the scene. */
function staticReady(chunk) {
const display = viewer.dataSourceDisplay;
if (typeof display?.getBoundingSphere !== 'function') return true;
if (!visible) return false;
chunk.frames += 1;
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
try {
return (
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
Cesium.BoundingSphereState.DONE
);
} catch {
return false;
}
}
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
function resolveStandIns() {
if (!usable()) return;
let changed = false;
for (const chunk of frozen) {
if (chunk.standIn && staticReady(chunk)) {
chunk.standIn = false;
changed = true;
}
}
if (changed) syncDynamic();
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
}
function stopListening() {
if (removeReadyListener) removeReadyListener();
removeReadyListener = null;
}
function listenForStandIns() {
if (removeReadyListener) return;
const event = viewer.scene?.postRender;
if (typeof event?.addEventListener !== 'function') {
resolveStandIns();
return;
}
removeReadyListener = event.addEventListener(resolveStandIns);
}
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
function dedupe(cartesians) {
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
return positions;
}
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
function sameRun(chunk, seg, start) {
const positions = chunk.positions;
if (start + positions.length > seg.length) return false;
for (let k = 0; k < positions.length; k++)
if (positions[k] !== seg[start + k]) return false;
return true;
}
/**
* Cut segments into chunks, reusing every frozen chunk whose points are
* still drawn in the same order. New chunks end where a reusable one begins,
* so dropping the oldest point rebuilds only the first chunk.
*/
function reconcile(segments) {
const byStart = new Map();
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
const kept = new Set();
const reusable = (seg, j) => {
const chunk = byStart.get(seg[j]);
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
};
const next = [];
let nextTail = NO_POSITIONS;
segments.forEach((seg, s) => {
const newest = s === segments.length - 1;
let i = 0;
while (i < seg.length - 1) {
const existing = reusable(seg, i);
if (existing) {
kept.add(existing);
next.push(existing);
i += existing.positions.length - 1;
continue;
}
if (newest && seg.length - i <= CHUNK_POINTS) {
nextTail = seg.slice(i);
break;
}
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
for (let j = i + 1; j < end; j++) {
if (reusable(seg, j)) {
end = j;
break;
}
}
const positions = seg.slice(i, end + 1);
next.push({
positions,
entity: addEntity(positions),
standIn: true,
frames: 0,
});
i = end;
}
});
for (const chunk of frozen)
if (!kept.has(chunk)) removeEntity(chunk.entity);
frozen = next;
tail = nextTail;
syncDynamic();
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
else stopListening();
}
function release() {
stopListening();
for (const chunk of frozen) removeEntity(chunk.entity);
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
}
return {
/**
* Replace the trail geometry with a chronological position list
* Replace the trail geometry with one chronological position list
* (oldest first). Fewer than 2 distinct positions clears the trail.
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
*/
setPositions(cartesians) {
if (destroyed || !viewer || viewer.isDestroyed()) return;
// Drop consecutive near-duplicates: zero-length segments add nothing.
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
current = positions.length >= 2 ? positions : [];
ensureEntity();
this.setSegments([cartesians]);
},
/**
* Replace the trail geometry with chronological segments (oldest first).
* Nothing is drawn between segments. A segment with fewer than 2 distinct
* positions draws nothing. Pass the same position objects on every
* update: a chunk whose points are unchanged is never rebuilt.
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
*/
setSegments(list) {
if (!usable()) return;
reconcile(
(Array.isArray(list) ? list : [])
.map(dedupe)
.filter((positions) => positions.length >= 2),
);
},
/** Temporarily hide/show the trail without discarding accumulated history. */
setVisible(nextVisible) {
visible = nextVisible !== false;
if (entity) entity.show = visible;
for (const entity of dynamicEntities) entity.show = visible;
for (const chunk of frozen) chunk.entity.show = visible;
},
/** Empty the trail without removing the entity (cheap re-arm). */
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
clear() {
current = [];
if (!usable()) return;
release();
},
/** Remove the trail entity permanently (layer disable/teardown). */
/** Remove every trail entity permanently (layer disable/teardown). */
destroy() {
destroyed = true;
current = [];
if (entity && viewer && !viewer.isDestroyed()) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
if (usable()) {
release();
for (const entity of dynamicEntities) removeEntity(entity);
}
entity = null;
destroyed = true;
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
dynamicEntities.length = 0;
stopListening();
},
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
_chunksForTest() {
return {
frozen: frozen.map((chunk) => ({
positions: chunk.positions,
standIn: chunk.standIn,
entity: chunk.entity,
})),
dynamic: dynamicRuns.slice(),
};
},
};
}
+251 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrail } from './trailRenderer.js';
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
test('trail visibility can change without discarding its accumulated geometry', () => {
const added = [];
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
trail.destroy();
assert.deepEqual(removed, [added[0]]);
});
/**
* A fake viewer. `ready` holds the frozen-chunk entities whose static
* geometry is in the scene; `frame()` runs one post-render pass.
*/
function fakeViewer({ scene = true } = {}) {
const added = [];
const removed = [];
const ready = new Set();
let listeners = [];
const viewer = {
added,
removed,
ready,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove(entity) {
removed.push(entity);
},
},
listenerCount: () => listeners.length,
frame() {
for (const listener of listeners.slice()) listener();
},
};
if (scene) {
viewer.scene = {
postRender: {
addEventListener(listener) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
},
};
viewer.dataSourceDisplay = {
getBoundingSphere: (entity) =>
ready.has(entity)
? Cesium.BoundingSphereState.DONE
: Cesium.BoundingSphereState.PENDING,
};
}
return viewer;
}
const valueOf = (positions) =>
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
/** Every line the scene can draw now: live entities with two or more points. */
function drawnRuns(viewer) {
return viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.map((entity) => valueOf(entity.polyline.positions))
.filter((positions) => positions.length >= 2);
}
/** Distinct position objects across all drawn lines. */
function drawnPoints(viewer) {
return new Set(drawnRuns(viewer).flat());
}
function line(count, offset = 0) {
return Array.from(
{ length: count },
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
);
}
test('a trail with breaks draws each segment and nothing between them', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const first = line(2, 0);
const lone = line(1, 50_000);
const second = line(3, 100_000);
trail.setSegments([first, lone, second]);
assert.deepEqual(
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
[first, second],
);
for (const entity of viewer.added) {
assert.match(String(entity.id), /^gev-trail:/);
assert.equal(entity.polyline.width, 2.5);
}
// The first segment is gone later: its geometry is released.
trail.setSegments([second]);
assert.deepEqual(drawnRuns(viewer), [second]);
trail.setVisible(false);
assert.ok(
viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.every((entity) => entity.show === false),
);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
});
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(300);
trail.setPositions(points);
const { frozen, dynamic } = trail._chunksForTest();
assert.deepEqual(
frozen.map((chunk) => chunk.positions.length),
[CHUNK_POINTS, CHUNK_POINTS],
);
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
assert.equal(dynamic.length, 1);
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
assert.equal(dynamic[0].at(-1), points.at(-1));
assert.equal(drawnPoints(viewer).size, points.length);
// A frozen chunk is constant geometry, not a per-frame callback.
for (const chunk of frozen)
assert.ok(Array.isArray(chunk.entity.polyline.positions));
});
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(CHUNK_POINTS + 20);
for (let n = 2; n <= points.length; n++) {
trail.setPositions(points.slice(0, n));
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
}
const { frozen } = trail._chunksForTest();
assert.equal(frozen.length, 1);
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
assert.ok(
trail._chunksForTest().dynamic.includes(frozen[0].positions),
'the dynamic copy stays until the static geometry is ready',
);
viewer.ready.add(frozen[0].entity);
viewer.frame();
const after = trail._chunksForTest();
assert.equal(after.frozen[0].standIn, false);
assert.ok(!after.dynamic.includes(frozen[0].positions));
assert.equal(after.dynamic.length, 1);
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
// The frozen entity is the same object: freezing built it once.
assert.equal(after.frozen[0].entity, frozen[0].entity);
});
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setVisible(false);
trail.setPositions(line(CHUNK_POINTS + 5));
const [chunk] = trail._chunksForTest().frozen;
viewer.ready.add(chunk.entity);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
trail.setVisible(true);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
});
test('dropping the oldest point rebuilds only the first chunk', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points);
const before = trail._chunksForTest().frozen;
trail.setPositions(points.slice(1));
const after = trail._chunksForTest().frozen;
assert.equal(after.length, before.length);
assert.notEqual(after[0].entity, before[0].entity);
assert.ok(viewer.removed.includes(before[0].entity));
for (let k = 1; k < before.length; k++)
assert.equal(after[k].entity, before[k].entity);
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
assert.equal(drawnPoints(viewer).size, 399);
});
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const live = line(300, 0);
trail.setPositions(live);
const before = trail._chunksForTest().frozen;
// History older than every live point, joined as one path.
const history = Array.from(
{ length: 200 },
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
);
trail.setPositions(history.concat(live));
const after = trail._chunksForTest().frozen;
for (const chunk of before)
assert.ok(after.some((c) => c.entity === chunk.entity));
for (let k = 0; k + 1 < after.length; k++)
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
assert.equal(drawnPoints(viewer).size, 500);
// A backfill that breaks from the live trail is its own segment.
trail.setSegments([history, live]);
assert.equal(drawnPoints(viewer).size, 500);
const runs = drawnRuns(viewer);
assert.ok(
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
'no line across the break',
);
});
test('clearing and re-tracking release every frozen chunk', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setPositions(line(600));
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
assert.ok(frozen.length >= 4);
assert.equal(viewer.listenerCount(), 1);
trail.clear();
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
assert.equal(viewer.listenerCount(), 0);
assert.equal(drawnRuns(viewer).length, 0);
// Another aircraft: nothing of the first one survives.
const other = line(200, 900_000);
trail.setPositions(other);
assert.equal(drawnPoints(viewer).size, 200);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
assert.equal(viewer.listenerCount(), 0);
});
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points.slice());
// A recorded point replaces the newest one; a spur point inside a frozen
// chunk and one at a chunk boundary go.
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
points.splice(200, 1);
points.splice(CHUNK_POINTS - 1, 1);
trail.setPositions(points.slice());
assert.deepEqual(drawnPoints(viewer), new Set(points));
// Every drawn line runs through consecutive trail points only.
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
for (const run of drawnRuns(viewer))
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
});
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
recordTrailPoint,
trailBodySegments,
trailPairBreaks,
trailDistanceM,
trailRecordingBlocked,
} from './trailContinuity.js';
// Reports decide where the line may run: recorded points never join two
// reports too far apart to be one flight, a report the display has already
// passed replaces the guess drawn after it, and old entries let go of the
// positions they stood for.
const SEC = 1000;
const LIMIT = 2_000;
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
);
const at = (e, n, u = 10) =>
Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(e, n, u),
new Cesium.Cartesian3(),
);
/** No two neighbours in one drawn segment are too far apart to be one flight. */
function assertNoBrokenJoin(fixes) {
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
for (const segment of trailBodySegments(fixes, fixes.length)) {
const inSegment = reports.filter((r) =>
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
);
for (let i = 1; i < inSegment.length; i++)
assert.equal(
trailPairBreaks(
inSegment[i].timeMs - inSegment[i - 1].timeMs,
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
),
false,
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
inSegment[i].timeMs / SEC
} s`,
);
}
}
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
for (let t = 1; t < 60; t++) {
// Nothing is recorded across the pair.
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
}
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
// Points recorded before the far report was known are dropped.
const late = [];
insertTrailFix(late, A, 0, LIMIT);
for (let t = 1; t < 60; t++)
recordTrailPoint(
late,
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
t * SEC,
LIMIT,
);
insertTrailFix(late, B, 60 * SEC, LIMIT);
assert.equal(late.filter((fix) => fix.drawn).length, 0);
assert.equal(trailBodySegments(late, late.length).length, 2);
assertNoBrokenJoin(late);
});
test('a coast, a hold and then a far report: nothing is drawn across', () => {
const A = at(0, 0, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
// The icon coasts east for 90 s at 230 m/s, then holds.
for (let t = 1; t <= 200; t++)
recordTrailPoint(
fixes,
at(230 * Math.min(t, 90), 0, 9_000),
t * SEC,
LIMIT,
);
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
// The next report is 150 km away two minutes later: over 1,500 kt.
const B = at(0, 150_000, 9_000);
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
assertNoBrokenJoin(fixes);
const segments = trailBodySegments(fixes, fixes.length);
const segmentOf = (p) =>
segments.findIndex((s) =>
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(segmentOf(A), segmentOf(B));
// No recorded point between the two reports remains.
assert.equal(
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
0,
);
});
for (const off of [200, 400, 600, 1_000]) {
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
const R = 2_000;
const w = 100 / R;
const pos = (t, r = R) =>
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
const fixes = [];
insertTrailFix(fixes, pos(0), 0, LIMIT);
for (let t = 1; t <= 60; t++)
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
const report = pos(50, R - off);
// It arrives with the display at 60 s; the ease takes 900 ms.
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
displayMs: 60 * SEC,
settleMs: 900,
});
// The guess drawn after the report is gone...
assert.deepEqual(
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
[],
);
// ...nothing is recorded while the icon eases onto the correction...
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
// ...and the line reads arc, report, corrected path.
for (let t = 61; t <= 70; t++)
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
const line = trailBodySegments(fixes, fixes.length).flat();
const r = line.findIndex(
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
);
assert.ok(r > 0);
for (const p of line.slice(0, r))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
'a point before the report is off the arc',
);
for (const p of line.slice(r + 1))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
31,
'a point after the report is off the corrected path',
);
});
}
test('entries behind the report before the display let go of the positions they stood for', () => {
const fixes = [];
for (let s = 0; s <= 2 * 3_600; s++) {
const p = at(230 * s, 0, 10_000);
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
}
let held = 0;
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
// Only the newest stretch, up to two reports back, keeps what it stood for.
assert.ok(held <= 30, `${held} positions still held`);
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
TRAIL_THIN_MAX_SPAN_M,
TRAIL_THIN_TOLERANCE_M,
} from './trailContinuity.js';
// Recorded points are thinned by shape: a point stays only where the line
// would move without it. Reports always stay.
const SEC = 1000;
const LIMIT = 2_000;
const LAT0 = 40;
const LON0 = -100;
const M_PER_DEG_LAT = 111_320;
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
/** Straight and level east at `speed` m/s, `tSec` after the start. */
const eastbound = (speed, tSec, heightM = 10_000) =>
Cesium.Cartesian3.fromDegrees(
LON0 + (speed * tSec) / mPerDegLon(LAT0),
LAT0,
heightM,
);
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
function turning(speed, tSec, heightM = 10_000) {
const omega = (3 * Math.PI) / 180;
const radius = speed / omega;
// Centre west of the start; heading north, turning left (towards west).
const angle = omega * tSec;
const east = -radius + radius * Math.cos(angle);
const north = radius * Math.sin(angle);
return Cesium.Cartesian3.fromDegrees(
LON0 + east / mPerDegLon(LAT0),
LAT0 + north / M_PER_DEG_LAT,
heightM,
);
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
const speed = 230;
const fixes = [];
for (const t of [0, 15, 30])
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
// Live order: a report arrives 30 s before the display reaches it, and the
// drawn icon is recorded once a second.
for (let t = 1; t <= 3_600; t++) {
const reportAt = t + 30;
if (reportAt % 15 === 0 && reportAt <= 3_600)
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
}
assert.ok(
drawnCount(fixes) < 60,
`${drawnCount(fixes)} recorded points kept`,
);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
});
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
const speed = 230;
const fixes = [];
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
for (let t = 1; t <= 3_600; t++)
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
const legKm = (speed * 3_600) / 1_000;
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
assert.ok(
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
`${drawnCount(fixes)} points over ${legKm} km`,
);
for (let i = 1; i < fixes.length; i++)
assert.ok(
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
TRAIL_THIN_MAX_SPAN_M + speed,
);
});
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
const speed = 230;
const fixes = [];
const recorded = [];
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
for (let t = 1; t <= 60; t++) {
const position = turning(speed, t);
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
for (const position of recorded) {
const off = distanceToPath(position, line);
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
}
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
});
test('a climb off the straight line keeps its vertices', () => {
const fixes = [];
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
// 30 s level, then a 60 s climb of 20 m/s, then level again.
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
const recorded = [];
for (let t = 1; t <= 150; t++) {
const position = eastbound(200, t, height(t));
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
for (const position of recorded)
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
});
test('at the limit, old recorded points go before any report from the backfill', () => {
const max = 300;
const backfill = [];
for (let i = 0; i < 100; i++)
backfill.push({
position: eastbound(200, i * 60 - 20_000),
timeMs: (i * 60 - 20_000) * SEC,
nullAltitude: false,
});
let fixes = [];
insertTrailFix(fixes, turning(150, 0), 0, max);
// A long series of turns keeps many recorded points.
for (let t = 1; t <= 2_000; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(drawnCount(fixes) > 150);
fixes = joinTrailBackfill(
{
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
breakAfter: false,
},
fixes,
max,
);
assert.ok(fixes.length <= max);
const reports = fixes.filter((fix) => !fix.drawn);
assert.equal(reports.length, 101, 'a report was dropped');
// More recording at the limit drops recorded points, never reports.
for (let t = 2_001; t <= 2_400; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(fixes.length <= max);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
// The recorded points that remain are the newest.
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
});
+798
View File
@@ -389,6 +389,179 @@ function rioRouteLabel(routeId) {
: routeId;
}
/**
* Carris Metropolitana realtime route ids wrap the GTFS route id in an
* operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS
* route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per
* the operator's own `/v2/routes` API. Anything else passes through.
* @param {string|null} routeId
* @returns {string|null}
*/
function carrisMetropolitanaRouteLabel(routeId) {
const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? '');
return match ? match[1] : routeId;
}
/**
* Route ids that wrap the rider-facing line in a fixed prefix, suffix or
* zero-padding. The returned function yields the captured line, or the id
* unchanged when it does not match the operator's pattern.
* @param {RegExp} pattern One capture group: the line a rider would say.
* @returns {(routeId: string|null) => string|null}
*/
function routeIdPattern(pattern) {
return (routeId) => {
const match = pattern.exec(routeId ?? '');
return match ? match[1] : routeId;
};
}
/** Zero-padded numbers (Edmonton `005`, Toulon `0002`, Bordeaux `01`). */
const unpaddedRouteLabel = routeIdPattern(/^0+(\d+\w*)$/);
/** Turin GTT appends `U` (urban) to the line: `10NU` is night line 10N. */
const gttRouteLabel = routeIdPattern(/^(\w+?)U$/);
/** Venice ACTV appends the network: `5.1_UN` is waterbus line 5.1, `53E_ES` bus 53E. */
const actvRouteLabel = routeIdPattern(/^(.+)_[A-Z]{2}$/);
/** BC Transit appends the system: `95-VIC` is Victoria route 95. */
const bcTransitRouteLabel = routeIdPattern(/^(.+)-VIC$/);
/** Carris (Lisbon city) appends a variant: `172_1` is route 172. */
const carrisRouteLabel = routeIdPattern(/^(\w+)_\d+$/);
/** Astuce prefixes the network: `TCAR:93` is Rouen line 93. */
const astuceNetworkLabel = routeIdPattern(/^[A-Z]+:(.+)$/);
/** BAS.MY Johor appends a fixed operator code: `J33CWLMYJB` is route J33. */
const mybasJohorRouteLabel = routeIdPattern(/^(.+?)CWLMYJB$/);
/** LiA prefixes its network: `8-C3` is Le Havre line C3, `8-09` line 9. */
const liaRouteLabel = routeIdPattern(/^8-0*(\w.*)$/);
/**
* Le Havre LiA runs its two tram lines as A and B and the funicular as
* `FUNI`; every other line is a bus.
* @param {string|null} routeId
* @returns {string}
*/
function liaRouteMode(routeId) {
if (!routeId) return 'unknown';
const line = liaRouteLabel(routeId);
if (line === 'FUNI') return 'rail';
return /^[AB]$/.test(line) ? 'tram' : 'bus';
}
/**
* Rouen Astuce lines that are not buses, by route id, from the TCAR
* routes.txt (checked 2026-11-09): line 90 is the Métro (GTFS type 1, a light
* rail that runs mostly on the street) and 99 the Calypso river boat.
*/
const ASTUCE_ROUTES = Object.freeze({
// i18n-ignore: line name, as on the vehicle
'TCAR:90': Object.freeze({ mode: 'tram', label: 'Métro' }),
// i18n-ignore: line name, as on the vehicle
'TCAR:99': Object.freeze({ mode: 'ferry', label: 'Calypso' }),
});
/** @param {string|null} routeId @returns {string} */
function astuceRouteMode(routeId) {
if (!routeId) return 'unknown';
return ASTUCE_ROUTES[routeId]?.mode ?? 'bus';
}
/** `TCAR:93` is Rouen line 93; the Métro and Calypso keep their names. */
function astuceRouteLabel(routeId) {
return ASTUCE_ROUTES[routeId ?? '']?.label ?? astuceNetworkLabel(routeId);
}
/**
* Prague PID route ids are `L` plus a number whose range says what runs it,
* per PID's GTFS routes.txt (data.pid.cz, checked 2026-11-09): 1–99 trams
* (50–59 trolleybuses, 49 the Petřín funicular `LD`), 100–990 buses (the
* 800s are the X replacement lines, `L803` is X3), 991–993 metro A–C,
* 1000s and 1100s the S and R trains (`L1004` is S4, `L1109` R9), the rest
* of 1200–1399 other trains, 1700s town buses, 1800s the P ferries, and from
* 2000 more buses (`L2841` is 841). A label the range cannot give stays the
* route id.
* @param {string|null} routeId
* @returns {{mode: string, label: string|null}}
*/
function pidRoute(routeId) {
const match = /^L(\d+)$/.exec(routeId ?? '');
if (!match) return { mode: 'unknown', label: routeId };
const n = Number(match[1]);
// i18n-ignore: line code, as signed
if (n === 49) return { mode: 'rail', label: 'LD' };
if (n >= 50 && n <= 59) return { mode: 'bus', label: String(n) };
if (n < 100) return { mode: 'tram', label: String(n) };
if (n >= 800 && n < 900) return { mode: 'bus', label: `X${n - 800}` };
if (n < 991) return { mode: 'bus', label: String(n) };
// i18n-ignore: metro line letters, as signed
if (n <= 993) return { mode: 'subway', label: 'ABC'[n - 991] };
if (n >= 1000 && n < 1100) return { mode: 'rail', label: `S${n - 1000}` };
if (n >= 1100 && n < 1200) return { mode: 'rail', label: `R${n - 1100}` };
if (n >= 1200 && n < 1400) return { mode: 'rail', label: routeId };
if (n >= 1800 && n < 1900) return { mode: 'ferry', label: `P${n - 1800}` };
if (n >= 2800 && n < 2900) return { mode: 'bus', label: String(n - 2000) };
return { mode: n >= 1700 ? 'bus' : 'unknown', label: routeId };
}
/** @param {string|null} routeId @returns {string} */
function pidRouteMode(routeId) {
return pidRoute(routeId).mode;
}
/** @param {string|null} routeId @returns {string|null} */
function pidRouteLabel(routeId) {
return pidRoute(routeId).label;
}
/**
* Bordeaux TBM tram and boat lines by route id, from TBM's static GTFS
* routes.txt (checked 2026-11-09): the trams are numbered ids, not letters.
*/
const TBM_TRAM_LINES = Object.freeze({
59: 'A',
60: 'B',
61: 'C',
62: 'D',
163: 'E',
164: 'F',
});
const TBM_BOAT_LINES = new Set(['951', '952', '953']);
/**
* Bordeaux TBM: trams A–F and the BAT³ river shuttles; every other line is a
* bus.
* @param {string|null} routeId
* @returns {string}
*/
function tbmRouteMode(routeId) {
if (!routeId) return 'unknown';
if (Object.hasOwn(TBM_TRAM_LINES, routeId)) return 'tram';
return TBM_BOAT_LINES.has(routeId) ? 'ferry' : 'bus';
}
/** `59` is tram A; zero-padded bus ids lose their padding (`01` is 1). */
function tbmRouteLabel(routeId) {
return Object.hasOwn(TBM_TRAM_LINES, routeId ?? '')
? TBM_TRAM_LINES[routeId]
: unpaddedRouteLabel(routeId);
}
/**
* A registry entry, frozen with its centre and terms, and switched on.
* @param {object} fields
* @returns {Readonly<object>}
*/
function transitFeed(fields) {
return Object.freeze({
defaultEnabled: true,
...fields,
center: Object.freeze({ ...fields.center }),
terms: Object.freeze({ ...fields.terms }),
...(fields.headers
? { headers: Object.freeze({ ...fields.headers }) }
: {}),
});
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -398,7 +571,14 @@ function rioRouteLabel(routeId) {
* url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string,
* speedUnit?: 'km/h', refreshMs?: number, sourceUrl?: string,
* creditDated?: boolean,
* }>>}
* `speedUnit` marks a feed that publishes km/h where the spec requires m/s;
* the proxy converts it before the snapshot leaves the server. `refreshMs`
* stretches the proxy's cache for an upstream that rate-limits below one
* request per 15 s TTL. `sourceUrl` and `creditDated` add a link and a date
* to the credit for licences that ask for them (`transitFeedCredit`).
*/
export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({
@@ -788,6 +968,624 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
transitFeed({
id: 'zditm-szczecin',
name: 'Szczecin buses and trams',
operator:
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM), Szczecin',
region: 'Szczecin, Poland',
// Every vehicle in a 2026-11-05 snapshot sat between 53.34–53.55 N and
// 14.44–14.77 E, inside 20 km of this point.
center: { lat: 53.44, lon: 14.56 },
loadRadiusKm: 25,
url: 'https://www.zditm.szczecin.pl/storage/gtfs/gtfs-rt-vehicles.pb',
license: 'CC0 1.0',
licenseUrl: 'https://www.zditm.szczecin.pl/en/zditm/for-developers',
attribution:
'Zarząd Dróg i Transportu Miejskiego w Szczecinie (ZDiTM Szczecin)',
terms: {
quote:
'The data are provided free of charge under the CC0 1.0 licence, for use in any context, with no prior notification required.',
note: 'CC0 needs no credit; it is given anyway as a courtesy. ZDiTM gives no guarantee of accuracy or completeness. The developer page says the feed updates every minute; measured on 2026-11-05 the file changed about every 20 seconds, with vehicle timestamps up to 50 seconds old. Speeds are m/s: checked against vehicle movement between two snapshots. The feed carries no route id at all, so every vehicle takes the feed default mode even though the city also runs trams.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'carris-metropolitana-lisboa',
name: 'Carris Metropolitana',
operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area',
region: 'Lisbon Metropolitan Area, Portugal',
// Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat
// between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point.
center: { lat: 38.73, lon: -9.13 },
loadRadiusKm: 45,
url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb',
license: 'No separate licence for the realtime feed',
licenseUrl: 'https://github.com/carrismetropolitana/api',
attribution: 'Carris Metropolitana',
terms: {
quote:
'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.',
note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.',
},
defaultMode: 'bus',
routeLabel: carrisMetropolitanaRouteLabel,
speedUnit: 'km/h',
}),
transitFeed({
id: 'pid-praha',
name: 'Prague PID',
operator: 'Pražská integrovaná doprava (PID), via Golemio',
region: 'Prague and Central Bohemia, Czechia',
// PID reaches across Central Bohemia: on 2026-11-08, 95% of 923 vehicles
// sat within 58 km of Prague, the farthest at 98 km.
center: { lat: 50.08, lon: 14.45 },
loadRadiusKm: 60,
url: 'https://api.golemio.cz/v2/vehiclepositions/gtfsrt/vehicle_positions.pb',
license: 'CC BY',
licenseUrl: 'https://pid.cz/o-systemu/opendata/',
attribution: 'Pražská integrovaná doprava (PID) / Golemio',
terms: {
quote:
'Data, která lze stáhnout přímo zde z webu, jsou stejně jako celý obsah webu opatřena licencí CC-BY',
note: 'The PID open-data page lists this realtime feed and licenses the data it publishes under CC BY, crediting the author and any changes. The same page says data served from other sources follows that source’s licence; the feed is served by Golemio, the city’s data platform, which states none of its own. Keyless since the catalogue moved it off the X-Access-Token endpoint. Speeds are m/s, checked against movement on 2026-11-07. Route ids are `L` plus a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1); `pidRoute` holds the table, read from PID’s GTFS routes.txt on 2026-11-09.',
},
defaultMode: 'bus',
routeLabel: pidRouteLabel,
routeMode: pidRouteMode,
}),
transitFeed({
id: 'ztm-warszawa',
name: 'Warsaw buses and trams',
operator: 'Miasto Stołeczne Warszawa, relayed by mkuran.pl',
region: 'Warsaw, Poland',
center: { lat: 52.25, lon: 21.02 },
loadRadiusKm: 30,
url: 'https://mkuran.pl/gtfs/warsaw/vehicles.pb',
license: 'Warsaw Open Data Project Usage Terms',
licenseUrl: 'https://mkuran.pl/gtfs/',
attribution: 'Miasto Stołeczne Warszawa (api.um.warszawa.pl)',
sourceUrl: 'http://api.um.warszawa.pl',
creditDated: true,
terms: {
quote:
'The City Hall requires a credit by providing the name Miasto Stołeczne Warszawa, data download date (use the timestamp from the FeedHeader) and link to http://api.um.warszawa.pl.',
note: 'Positions come from the Warsaw City Hall API, repackaged as GTFS-RT by Mikołaj Kuranowski’s unofficial feed. The credit names the city, links its API (`sourceUrl`) and carries the date of the feed timestamp (`creditDated`), the three things the terms ask for. Vehicle timestamps ran about two minutes old on 2026-11-07, which the delayed playback absorbs.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'tbm-bordeaux',
name: 'Bordeaux TBM',
operator: 'Transports Bordeaux Métropole (TBM), Bordeaux Métropole',
region: 'Bordeaux, France',
center: { lat: 44.85, lon: -0.65 },
loadRadiusKm: 20,
url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
license: 'Licence Ouverte / Etalab 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite',
attribution: 'Bordeaux Métropole (TBM)',
terms: {
quote: 'licence: fr-lo',
note: 'The quote is the licence field of the national access point’s dataset record (fr-lo is the Licence Ouverte), which asks only for credit. The `apiKey` in the URL is not a credential: it is the same fixed string printed in the open-data listing for everyone. Speeds are m/s. The trams (A–F) and BAT³ boats ride numbered route ids (tram A is `59`), so `tbmRouteMode` and `tbmRouteLabel` map them from TBM’s static GTFS; other numbered lines are buses.',
},
defaultMode: 'bus',
routeLabel: tbmRouteLabel,
routeMode: tbmRouteMode,
}),
transitFeed({
id: 'sendai-city-bus',
name: 'Sendai City Bus',
operator: 'Sendai City Transportation Bureau (仙台市交通局), via ODPT',
region: 'Sendai, Japan',
center: { lat: 38.27, lon: 140.82 },
loadRadiusKm: 22,
url: 'https://api-public.odpt.org/api/v4/gtfs/realtime/odpt_SendaiMunicipal_bus_realtime_information_vehicle',
license: 'CC BY 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
attribution: '仙台市交通局 (Sendai City Transportation Bureau) / ODPT',
terms: {
quote: 'https://creativecommons.org/licenses/by/4.0/',
note: 'Served by the Open Data Challenge for Public Transportation (ODPT) public API, which needs no key for this feed; the catalogue records CC BY 4.0 as its licence. Vehicle timestamps ran about two minutes old on 2026-11-07. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztm-poznan',
name: 'Poznań buses and trams',
operator: 'Zarząd Transportu Miejskiego w Poznaniu (ZTM Poznań)',
region: 'Poznań, Poland',
center: { lat: 52.39, lon: 16.92 },
loadRadiusKm: 20,
url: 'https://www.ztm.poznan.pl/pl/dla-deweloperow/getGtfsRtFile/?file=vehicle_positions.pb',
license: 'ZTM Poznań open-data terms',
licenseUrl: 'https://www.ztm.poznan.pl/otwarte-dane/dla-deweloperow/',
attribution: 'Zarząd Transportu Miejskiego w Poznaniu (processed)',
creditDated: true,
terms: {
quote:
'Treści udostępniane są bezwnioskowo i bezpłatnie, na zasadach powszechnego dostępu i stanowią źródło informacji.',
note: 'Reuse is free on three conditions: name the source, give the date of the last update, and say the data were processed. The credit names ZTM, says processed, and carries the date of the feed timestamp (`creditDated`) as the update date. The feed carries no bearing, so heading comes from movement between fixes.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztm-gdansk',
name: 'Gdańsk buses and trams',
operator: 'Zarząd Transportu Miejskiego w Gdańsku (ZTM Gdańsk)',
region: 'Gdańsk, Poland',
center: { lat: 54.35, lon: 18.6 },
loadRadiusKm: 20,
url: 'https://ckan2.multimediagdansk.pl/gtfs-rt?feed=vehiclePositions',
license: 'CC BY 4.0',
licenseUrl:
'https://dane.gov.pl/pl/dataset/38599,rozklady-jazdy-i-lokalizacja-przystankow-gtfs-rt',
attribution: 'Zarząd Transportu Miejskiego w Gdańsku',
terms: {
note: 'The national catalogue (dane.gov.pl) lists this dataset under CC BY 4.0. No bearing; heading comes from movement. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ets-edmonton',
name: 'Edmonton Transit Service',
operator: 'City of Edmonton (Edmonton Transit Service)',
region: 'Edmonton, AB',
center: { lat: 53.53, lon: -113.52 },
loadRadiusKm: 25,
url: 'https://gtfs.edmonton.ca/TMGTFSRealTimeWebService/Vehicle/VehiclePositions.pb',
license: 'City of Edmonton Open Data Agreement',
licenseUrl:
'https://www.edmonton.ca/transportation/Web-version2.1-OpenDataAgreement.pdf',
attribution:
'Contains information licensed under the Open Data Agreement – City of Edmonton',
terms: {
note: 'The agreement is the same municipal template as Mississauga and Burlington: a licence to use, modify and distribute for any lawful purpose, in any medium. It was read from search excerpts; the PDF itself would not extract here. Route ids are zero-padded. Speeds are m/s.',
},
defaultMode: 'bus',
routeLabel: unpaddedRouteLabel,
}),
transitFeed({
id: 'halifax-transit',
name: 'Halifax Transit',
operator: 'Halifax Regional Municipality (Halifax Transit)',
region: 'Halifax, NS',
center: { lat: 44.7, lon: -63.6 },
loadRadiusKm: 22,
url: 'https://gtfs.halifax.ca/realtime/Vehicle/VehiclePositions.pb',
license: 'Open Government Licence – Halifax',
licenseUrl: 'https://www.halifax.ca/home/open-data/open-data-license',
attribution:
'Contains information licenced under the Open Government Licence – Halifax',
terms: {
quote:
'The information provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes, subject to the terms below.',
note: 'Version 1.0, modelled on the Open Government Licence – Canada 2.0. The attribution is the licence’s own standard statement, with a link to the licence where possible. Logos and crests are excluded and no endorsement may be implied, so the credit stays plain text. The page refuses automated requests (403); its text was read in a browser on 2026-11-08. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'bc-transit-victoria',
name: 'Victoria Regional Transit',
operator: 'BC Transit (Victoria Regional Transit System)',
region: 'Victoria, BC',
center: { lat: 48.53, lon: -123.43 },
loadRadiusKm: 28,
url: 'https://bct.tmix.se/gtfs-realtime/vehicleupdates.pb?operatorIds=48',
license: 'BC Transit Open Data Terms of Use',
licenseUrl: 'https://www.bctransit.com/open-data/terms-of-use',
attribution: 'BC Transit',
terms: {
quote:
'BC Transit grants you a limited, revocable and non-exclusive license to use, reproduce, and redistribute the Data in accordance with these Terms.',
note: 'The grant is revocable, like the Ontario municipal templates. Route ids carry a `-VIC` system suffix. Speeds are m/s.',
},
defaultMode: 'bus',
routeLabel: bcTransitRouteLabel,
}),
transitFeed({
id: 'tam-montpellier',
name: 'Montpellier TaM',
operator: 'Montpellier Méditerranée Métropole (TaM)',
region: 'Montpellier, France',
center: { lat: 43.61, lon: 3.87 },
loadRadiusKm: 20,
url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb',
license: 'ODbL',
licenseUrl:
'https://transport.data.gouv.fr/datasets/offre-de-transport-tam-en-temps-reel-gtfs-rt-urbain-et-suburbain',
attribution: 'Montpellier Méditerranée Métropole (TaM)',
terms: {
quote: 'licence: odc-odbl',
note: 'The quote is the dataset record’s licence field. ODbL asks for credit and share-alike on a derived database; the layer redistributes no database, it draws the positions. Speeds are km/h: readings ran 3.3 times the measured movement on 2026-11-07, so the proxy converts them.',
},
defaultMode: 'bus',
speedUnit: 'km/h',
}),
transitFeed({
id: 'ztp-krakow-bus',
name: 'Kraków buses',
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
region: 'Kraków, Poland',
center: { lat: 50.06, lon: 19.94 },
loadRadiusKm: 22,
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_A.pb',
license: 'No licence stated',
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
attribution: 'Zarząd Transportu Publicznego w Krakowie',
terms: {
note: 'ZTP publishes this feed keyless on its own GTFS server and lists it in the Mobility Database, but states no licence there or on dane.gov.pl. Shipped on the same basis as Metro Transit and OVapi; switch it off if ZTP objects.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ztp-krakow-tram',
name: 'Kraków trams',
operator: 'Zarząd Transportu Publicznego w Krakowie (ZTP Kraków)',
region: 'Kraków, Poland',
center: { lat: 50.06, lon: 19.94 },
loadRadiusKm: 22,
url: 'https://gtfs.ztp.krakow.pl/VehiclePositions_T.pb',
license: 'No licence stated',
licenseUrl: 'https://gtfs.ztp.krakow.pl/',
attribution: 'Zarząd Transportu Publicznego w Krakowie',
terms: { note: 'Same server and basis as the bus feed.' },
defaultMode: 'tram',
}),
transitFeed({
id: 'vilnius-transport',
name: 'Vilnius public transport',
operator: 'SĮ Susisiekimo paslaugos, Vilnius',
region: 'Vilnius, Lithuania',
center: { lat: 54.69, lon: 25.28 },
loadRadiusKm: 20,
url: 'https://www.stops.lt/vilnius/vehicle_positions.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2383',
attribution: 'SĮ Susisiekimo paslaugos (stops.lt)',
terms: {
note: 'Published keyless by the city transport agency on stops.lt; no licence found. Shipped on the same basis as Metro Transit and OVapi. Answered 522 once on 2026-11-07 and normally since.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'tpbi-bucuresti',
name: 'Bucharest public transport',
operator: 'Asociația de Dezvoltare Intercomunitară TPBI, Bucharest–Ilfov',
region: 'Bucharest, Romania',
center: { lat: 44.43, lon: 26.1 },
loadRadiusKm: 25,
url: 'https://gtfs.tpbi.ro/api/gtfs-rt/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2104',
attribution: 'TPBI (Transport Public București–Ilfov)',
terms: {
note: 'Keyless public endpoint from the metropolitan transport authority; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'roma-mobilita',
name: 'Rome public transport',
operator: 'Roma Servizi per la Mobilità',
region: 'Rome, Italy',
center: { lat: 41.89, lon: 12.5 },
loadRadiusKm: 35,
url: 'https://romamobilita.it/wp-content/uploads/shared/rome_rtgtfs_vehicle_positions_feed.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1776',
attribution: 'Roma Servizi per la Mobilità',
terms: {
note: 'Keyless file on the agency’s own site; the catalogue’s older `/sites/default/files/` path now 404s. No licence found; same basis as Metro Transit and OVapi. Fewer than one vehicle in five carries a bearing, so most headings come from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'gtt-torino',
name: 'Turin GTT',
operator: 'Gruppo Torinese Trasporti (GTT)',
region: 'Turin, Italy',
center: { lat: 45.07, lon: 7.68 },
loadRadiusKm: 20,
url: 'https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2696',
attribution: 'Gruppo Torinese Trasporti (GTT)',
terms: {
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Route ids append `U` for urban lines.',
},
defaultMode: 'bus',
routeLabel: gttRouteLabel,
}),
transitFeed({
id: 'actv-venezia-bus',
name: 'Venice ACTV buses',
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV)',
region: 'Venice, Italy',
center: { lat: 45.4, lon: 12.2 },
loadRadiusKm: 38,
url: 'https://tpl.actv.it/aut/GTFSRT/vehiclePositions',
license: 'ACTV open-data catalogue',
licenseUrl:
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
attribution: 'ACTV S.p.A.',
terms: {
note: 'ACTV lists this feed in its own open-data catalogue; that page would not render for a script, so its licence is not quoted. Same basis as Metro Transit and OVapi. Route ids append a two-letter network code (`_UM`, `_ES`, `_UN`).',
},
defaultMode: 'bus',
routeLabel: actvRouteLabel,
}),
transitFeed({
id: 'actv-venezia-waterbus',
name: 'Venice ACTV waterbuses',
operator: 'Azienda del Consorzio Trasporti Veneziano (ACTV) Navigazione',
region: 'Venice, Italy',
center: { lat: 45.43, lon: 12.33 },
loadRadiusKm: 15,
url: 'https://tpl.actv.it/nav/GTFSRT/vehiclePositions',
license: 'ACTV open-data catalogue',
licenseUrl:
'https://actv.avmspa.it/it/content/catalogo-dei-dati-metadati-e-banche-dati-actv',
attribution: 'ACTV S.p.A.',
terms: { note: 'Same catalogue and basis as the ACTV bus feed.' },
defaultMode: 'ferry',
routeLabel: actvRouteLabel,
}),
transitFeed({
id: 'adelaide-metro',
name: 'Adelaide Metro',
operator: 'Adelaide Metro (Government of South Australia)',
region: 'Adelaide, SA',
center: { lat: -34.93, lon: 138.6 },
loadRadiusKm: 35,
url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions',
// Any Accept naming a protobuf type gets base64 text labelled JSON; only
// */* returns the binary feed (checked 2026-11-08).
headers: { Accept: '*/*' },
license: 'No licence stated for the realtime feed',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1772',
attribution: 'Adelaide Metro',
terms: {
note: 'Official keyless endpoint; the state catalogue page refused automated reads, so no licence is quoted. Same basis as Metro Transit and OVapi. Speeds are m/s. The server picks its encoding from Accept, hence the override.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'marta-atlanta',
name: 'MARTA buses',
operator: 'Metropolitan Atlanta Rapid Transit Authority (MARTA)',
region: 'Atlanta, GA',
center: { lat: 33.78, lon: -84.39 },
loadRadiusKm: 35,
url: 'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-1601',
attribution: 'MARTA',
terms: {
note: 'Official keyless endpoint; no licence found. Same basis as Metro Transit and OVapi. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'rtd-denver',
name: 'Denver RTD',
operator: 'Regional Transportation District (RTD), Denver',
region: 'Denver, CO',
center: { lat: 39.75, lon: -104.99 },
loadRadiusKm: 55,
url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb',
license: 'RTD GTFS-realtime licence agreement',
licenseUrl:
'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement',
attribution: 'Regional Transportation District (RTD)',
terms: {
quote:
'RTD hereby grants you non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data',
note: 'Read 2026-11-09. The agreement is a click-through (“By clicking to download the Data files, you confirm that you have read and agree”) that RTD may change without notice; it permits redistribution, asks for no credit wording, and bars RTD trademarks from being used with the data, so the credit stays plain text. The feed itself is keyless. Speeds are m/s.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ctp-cluj',
name: 'Cluj-Napoca CTP',
operator: 'Compania de Transport Public Cluj-Napoca',
region: 'Cluj-Napoca, Romania',
center: { lat: 46.77, lon: 23.59 },
loadRadiusKm: 15,
url: 'https://cluj-rt-feed.gtfs.ro/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2614',
attribution: 'Compania de Transport Public Cluj-Napoca',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'ctp-iasi',
name: 'Iași CTP',
operator: 'Compania de Transport Public Iași',
region: 'Iași, Romania',
center: { lat: 47.16, lon: 27.59 },
loadRadiusKm: 15,
url: 'https://iasi-rt-feed.gtfs.ro/vehiclePositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2118',
attribution: 'Compania de Transport Public Iași',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'mpk-rzeszow',
name: 'Rzeszów city transport',
operator: 'Rzeszowski Transport Miejski (MPK Rzeszów)',
region: 'Rzeszów, Poland',
center: { lat: 50.04, lon: 22.0 },
loadRadiusKm: 15,
url: 'https://www.mpkrzeszow.pl/gtfs/rt/gtfsrt.pb',
license: 'MPK Rzeszów open data',
licenseUrl: 'https://www.mpkrzeszow.pl/otwartedane/',
attribution: 'MPK Rzeszów',
terms: {
note: 'Listed on the operator’s open-data page, which names no licence. No bearing; heading comes from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'emt-malaga',
name: 'Málaga EMT',
operator: 'Empresa Malagueña de Transportes (EMT)',
region: 'Málaga, Spain',
center: { lat: 36.72, lon: -4.42 },
loadRadiusKm: 15,
url: 'https://gtfs.emtmalaga.es/GTFSRTapi/api/vehicleposition',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2213',
attribution: 'EMT Málaga',
terms: {
note: 'Keyless; no licence found. Same basis as Metro Transit and OVapi. No bearing; heading comes from movement.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'carris-lisboa',
name: 'Carris (Lisbon city)',
operator: 'Carris – Companhia Carris de Ferro de Lisboa',
region: 'Lisbon, Portugal',
center: { lat: 38.74, lon: -9.15 },
loadRadiusKm: 15,
url: 'https://gateway.carris.pt/gateway/gtfs/api/v2.11/GTFS/realtime/vehiclepositions',
license: 'No licence stated',
licenseUrl: 'https://mobilitydatabase.org/feeds/gtfs_rt/mdb-2930',
attribution: 'Carris',
terms: {
note: 'Keyless official gateway; no licence found. Same basis as Metro Transit and OVapi. Carries neither bearing nor speed, and vehicle reports run about a minute old. Route ids append a variant.',
},
defaultMode: 'bus',
routeLabel: carrisRouteLabel,
}),
transitFeed({
id: 'mybas-johor',
name: 'BAS.MY Johor Bahru',
operator: 'BAS.MY, via data.gov.my',
region: 'Johor Bahru, Malaysia',
center: { lat: 1.51, lon: 103.75 },
loadRadiusKm: 35,
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-johor/',
license: 'CC BY 4.0',
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
attribution: 'BAS.MY / data.gov.my',
// data.gov.my allows 4 GTFS-Realtime requests a minute and refreshes
// vehicle positions every 30 seconds, so the proxy asks half as often as
// the default 15 s TTL would.
refreshMs: 30_000,
terms: {
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
note: 'Served keyless by Malaysia’s national open-data API (developer.data.gov.my, read 2026-11-09). Its rate-limit page caps GTFS Realtime at 4 requests a minute and answers 429 above that; the feed updates every 30 seconds, hence `refreshMs`. Without the trailing slash the API redirects. Route ids append an operator code.',
},
defaultMode: 'bus',
routeLabel: mybasJohorRouteLabel,
}),
transitFeed({
id: 'mybas-kota-bharu',
name: 'BAS.MY Kota Bharu',
operator: 'BAS.MY, via data.gov.my',
region: 'Kota Bharu, Malaysia',
// Routes run south into Kelantan: 95% of vehicles within 58 km on 2026-11-08.
center: { lat: 6.06, lon: 102.22 },
loadRadiusKm: 60,
url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/mybas-kota-bharu/',
license: 'CC BY 4.0',
licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime',
attribution: 'BAS.MY / data.gov.my',
refreshMs: 30_000,
terms: {
quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0.',
note: 'Same API, licence and rate limit as Johor Bahru.',
},
defaultMode: 'bus',
}),
transitFeed({
id: 'astuce-rouen',
name: 'Rouen Astuce',
operator: 'Métropole Rouen Normandie (Astuce), réseau TCAR',
region: 'Rouen, France',
center: { lat: 49.44, lon: 1.1 },
loadRadiusKm: 20,
url: 'https://api.mrn.cityway.fr/dataflow/vehicle-tc-tr/download?provider=TCAR&dataFormat=gtfs-rt',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/donnees-statiques-et-temps-reel-du-reseau-astuce-metropole-rouen-normandie',
attribution: 'Métropole Rouen Normandie (Astuce)',
terms: {
quote: 'licence: lov2',
note: 'The metropolis publishes its realtime data under the Licence Ouverte (dataset record quoted), and this is its own TCAR vehicle feed from that record (178 vehicles on 2026-11-09). The suburban TAE and TNI networks are separate feeds in the same record and are not read here. Route ids carry the network prefix (`TCAR:`); `TCAR:90` is the Métro (a street-running light rail, drawn as a tram) and `TCAR:99` the Calypso boat.',
},
defaultMode: 'bus',
routeLabel: astuceRouteLabel,
routeMode: astuceRouteMode,
}),
transitFeed({
id: 'mpm-toulon',
name: 'Toulon Mistral',
operator: 'Métropole Toulon Provence Méditerranée (réseau Mistral)',
region: 'Toulon, France',
center: { lat: 43.12, lon: 5.94 },
loadRadiusKm: 18,
url: 'https://feed-rdtpm-toulon.ratpdev.com/VehiclePosition/GTFS-RT',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/reseau-de-transport-urbain-de-la-metropole-toulon-provence-mediterranee',
attribution: 'Métropole Toulon Provence Méditerranée (Mistral)',
terms: {
quote: 'licence: lov2',
note: 'Dataset record’s licence field quoted. Speeds are km/h: readings ran 3.5 times the measured movement on 2026-11-07, so the proxy converts them. Route ids are zero-padded.',
},
defaultMode: 'bus',
routeLabel: unpaddedRouteLabel,
speedUnit: 'km/h',
}),
transitFeed({
id: 'lia-le-havre',
name: 'Le Havre LiA',
operator: 'Le Havre Seine Métropole (LiA)',
region: 'Le Havre, France',
center: { lat: 49.49, lon: 0.11 },
loadRadiusKm: 18,
url: 'https://opendata.transports-lia.fr/gtfs-rt/VehiclePosition.pb',
license: 'ODbL',
licenseUrl:
'https://transport.data.gouv.fr/datasets/reseau-de-transport-en-commun-lia-gtfs-siri',
attribution: 'Le Havre Seine Métropole (LiA)',
terms: {
quote: 'licence: odc-odbl',
note: 'The network publishes under ODbL (dataset record quoted), and this is its own GTFS-RT vehicle feed from that record (105 vehicles on 2026-11-09). The layer draws positions and redistributes no database. Route ids carry the network prefix `8-`; lines A and B are the tramway.',
},
defaultMode: 'bus',
routeLabel: liaRouteLabel,
routeMode: liaRouteMode,
}),
transitFeed({
id: 'filbleu-tours',
name: 'Tours Fil Bleu',
operator: 'Syndicat des Mobilités de Touraine (Fil Bleu)',
region: 'Tours, France',
center: { lat: 47.39, lon: 0.69 },
loadRadiusKm: 18,
url: 'https://data.filbleu.fr/ws-tr/gtfs-rt/opendata/vehicle-positions',
license: 'Licence Ouverte 2.0',
licenseUrl:
'https://transport.data.gouv.fr/datasets/fil-bleu-syndicat-des-mobilites-gtfs-gtfs-rt',
attribution: 'Fil Bleu – Syndicat des Mobilités de Touraine',
terms: {
quote: 'licence: lov2',
note: 'Dataset record’s licence field quoted.',
},
defaultMode: 'bus',
}),
]);
/**
+226
View File
@@ -552,3 +552,229 @@ test('Rio feeds are CC BY 4.0 and live', () => {
'Rio has a DATA_SOURCES row',
);
});
test('Szczecin is CC0, live, covers the city and not Berlin', () => {
const feed = getTransitFeed('zditm-szczecin');
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.license, 'CC0 1.0');
assert.equal(
feed.licenseUrl,
'https://www.zditm.szczecin.pl/en/zditm/for-developers',
);
assert.match(feed.terms.quote, /CC0 1\.0 licence/);
assert.match(feed.attribution, /ZDiTM/);
assert.equal(feed.routeLabel, undefined, 'the feed has no route ids');
assert.equal(transitModeFor(feed, null), 'bus');
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
assert.ok(ids(53.4285, 14.5528).includes('zditm-szczecin'), 'city centre');
assert.ok(ids(53.5, 14.7).includes('zditm-szczecin'), 'north-east edge');
assert.ok(!ids(52.52, 13.405).includes('zditm-szczecin'), 'Berlin');
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources.split('\n').some((l) => l.startsWith('| **ZDiTM Szczecin')),
'Szczecin has a DATA_SOURCES row',
);
});
test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb');
assert.equal(feed.speedUnit, 'km/h');
assert.match(feed.terms.note, /no licence for the realtime endpoint/);
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
for (const [place, lat, lon] of [
['Lisbon', 38.7223, -9.1393],
['Mafra', 38.9369, -9.3277],
['Setúbal', 38.5244, -8.8882],
]) {
assert.ok(ids(lat, lon).includes(feed.id), place);
}
assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto');
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')),
'Lisbon has a DATA_SOURCES row',
);
});
test('Carris Metropolitana route ids read as the line number on the bus', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708');
assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772');
assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected');
assert.equal(transitRouteLabel(feed, null), null);
});
// The 2026-11 batch: one landmark per feed, inside its own radius.
const NEW_CITY_LANDMARKS = {
'pid-praha': [50.0875, 14.4213],
'ztm-warszawa': [52.2297, 21.0122],
'tbm-bordeaux': [44.8378, -0.5792],
'sendai-city-bus': [38.2602, 140.8824],
'ztm-poznan': [52.4064, 16.9252],
'ztm-gdansk': [54.352, 18.6466],
'ets-edmonton': [53.5461, -113.4938],
'halifax-transit': [44.6488, -63.5752],
'bc-transit-victoria': [48.4284, -123.3656],
'tam-montpellier': [43.6108, 3.8767],
'ztp-krakow-bus': [50.0614, 19.9366],
'ztp-krakow-tram': [50.0614, 19.9366],
'vilnius-transport': [54.6872, 25.2797],
'tpbi-bucuresti': [44.4268, 26.1025],
'roma-mobilita': [41.9028, 12.4964],
'gtt-torino': [45.0703, 7.6869],
'actv-venezia-bus': [45.4408, 12.3155],
'actv-venezia-waterbus': [45.4408, 12.3155],
'adelaide-metro': [-34.9285, 138.6007],
'marta-atlanta': [33.749, -84.388],
'rtd-denver': [39.7392, -104.9903],
'ctp-cluj': [46.7712, 23.6236],
'ctp-iasi': [47.1585, 27.6014],
'mpk-rzeszow': [50.0412, 21.9991],
'emt-malaga': [36.7213, -4.4214],
'carris-lisboa': [38.7223, -9.1393],
'mybas-johor': [1.4927, 103.7414],
'mybas-kota-bharu': [6.1254, 102.2381],
'astuce-rouen': [49.4432, 1.0999],
'mpm-toulon': [43.1242, 5.928],
'lia-le-havre': [49.4944, 0.1079],
'filbleu-tours': [47.3941, 0.6848],
};
test('every city in the 2026-11 batch is live, licensed and covers its own centre', () => {
for (const [id, [lat, lon]] of Object.entries(NEW_CITY_LANDMARKS)) {
const feed = getTransitFeed(id);
assert.ok(feed, `${id} is routable`);
assert.ok(
publicTransitCatalog().some((f) => f.id === id),
`${id} is offered to the browser`,
);
assert.ok(feed.terms?.note, `${id} records the basis it ships on`);
assert.ok(
transitFeedsInRange(lat, lon).some((f) => f.id === id),
`${id} covers its city`,
);
}
// A feed's radius stays local: Warsaw is not polled for Kraków.
assert.ok(
!transitFeedsInRange(50.0614, 19.9366).some((f) => f.id === 'ztm-warszawa'),
);
});
test('feeds with no stated licence say so and name the basis they ship on', () => {
for (const feed of TRANSIT_FEED_REGISTRY.filter((f) =>
/^No licence stated/.test(f.license),
)) {
assert.match(
feed.terms.note,
/Metro Transit and OVapi|Same .*basis/,
`${feed.id} names its basis`,
);
}
});
test('route ids in the 2026-11 batch read as the line on the vehicle', () => {
const label = (id, routeId) => transitRouteLabel(getTransitFeed(id), routeId);
assert.equal(label('pid-praha', 'L135'), '135');
assert.equal(label('ets-edmonton', '009'), '9');
assert.equal(label('mpm-toulon', '0002'), '2');
assert.equal(label('gtt-torino', '10NU'), '10N');
assert.equal(label('actv-venezia-waterbus', '5.1_UN'), '5.1');
assert.equal(label('actv-venezia-bus', '53E_ES'), '53E');
assert.equal(label('bc-transit-victoria', '95-VIC'), '95');
assert.equal(label('carris-lisboa', '172_1'), '172');
assert.equal(label('astuce-rouen', 'TCAR:93'), '93');
assert.equal(label('mybas-johor', 'J33CWLMYJB'), 'J33');
// Unmatched and absent ids pass through untouched.
assert.equal(label('pid-praha', 'S4'), 'S4');
assert.equal(label('gtt-torino', null), null);
});
test('trams, waterbuses and buses are told apart where the ids allow it', () => {
const mode = (id, routeId) => transitModeFor(getTransitFeed(id), routeId);
assert.equal(mode('pid-praha', 'L22'), 'tram');
assert.equal(mode('pid-praha', 'L135'), 'bus');
assert.equal(mode('tbm-bordeaux', '16'), 'bus');
assert.equal(mode('lia-le-havre', '8-B'), 'tram');
assert.equal(mode('lia-le-havre', '8-C3'), 'bus');
assert.equal(mode('ztp-krakow-tram', null), 'tram');
assert.equal(mode('actv-venezia-waterbus', '1_UN'), 'ferry');
});
test('feeds that publish km/h are marked for conversion, and only those', () => {
const kmh = TRANSIT_FEED_REGISTRY.filter((f) => f.speedUnit === 'km/h')
.map((f) => f.id)
.sort();
assert.deepEqual(kmh, [
'carris-metropolitana-lisboa',
'mpm-toulon',
'tam-montpellier',
]);
});
// Route ids as the live feeds and the operators' own GTFS routes.txt carried
// them on 2026-11-09: the ranges and numbered tram ids are not guessable from
// the line names riders see.
test('Prague route-id ranges give metro, trains, ferries and trolleybuses their mode', () => {
const feed = getTransitFeed('pid-praha');
const read = (routeId) => [
transitModeFor(feed, routeId),
transitRouteLabel(feed, routeId),
];
assert.deepEqual(read('L22'), ['tram', '22']);
assert.deepEqual(read('L58'), ['bus', '58'], 'trolleybus');
assert.deepEqual(read('L49'), ['rail', 'LD'], 'Petřín funicular');
assert.deepEqual(read('L135'), ['bus', '135']);
assert.deepEqual(read('L803'), ['bus', 'X3']);
assert.deepEqual(read('L991'), ['subway', 'A']);
assert.deepEqual(read('L993'), ['subway', 'C']);
assert.deepEqual(read('L1004'), ['rail', 'S4']);
assert.deepEqual(read('L1109'), ['rail', 'R9']);
assert.deepEqual(read('L1204'), ['rail', 'L1204'], 'no label to derive');
assert.deepEqual(read('L1801'), ['ferry', 'P1']);
assert.deepEqual(read('L1703'), ['bus', 'L1703']);
assert.deepEqual(read('L2841'), ['bus', '841']);
assert.deepEqual(read('S4'), ['bus', 'S4'], 'unknown ids keep the default');
});
test('Bordeaux trams ride numbered route ids and keep their letters', () => {
const feed = getTransitFeed('tbm-bordeaux');
for (const [routeId, line] of [
['59', 'A'],
['62', 'D'],
['163', 'E'],
]) {
assert.equal(transitModeFor(feed, routeId), 'tram', routeId);
assert.equal(transitRouteLabel(feed, routeId), line);
}
assert.equal(transitModeFor(feed, '952'), 'ferry');
assert.equal(transitModeFor(feed, '01'), 'bus');
assert.equal(transitRouteLabel(feed, '01'), '1');
});
test('Rouen Métro and Calypso are not buses; Le Havre reads its own ids', () => {
const feed = getTransitFeed('astuce-rouen');
assert.equal(transitModeFor(feed, 'TCAR:90'), 'tram');
assert.equal(transitRouteLabel(feed, 'TCAR:90'), 'Métro');
assert.equal(transitModeFor(feed, 'TCAR:99'), 'ferry');
assert.equal(transitModeFor(feed, 'TCAR:93'), 'bus');
assert.equal(transitRouteLabel(feed, 'TAE:F'), 'F');
const lia = getTransitFeed('lia-le-havre');
assert.equal(transitModeFor(lia, '8-FUNI'), 'rail');
assert.equal(transitRouteLabel(lia, '8-C3'), 'C3');
assert.equal(transitRouteLabel(lia, '8-09'), '9');
assert.equal(transitRouteLabel(lia, '8-A'), 'A');
});
test('data.gov.my feeds ask the proxy for no more than its 4 a minute', () => {
for (const id of ['mybas-johor', 'mybas-kota-bharu']) {
assert.ok(getTransitFeed(id).refreshMs >= 30_000, id);
}
});
+22 -3
View File
@@ -257,24 +257,43 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
truncated: decoded.truncated,
count: decoded.vehicles.length,
vehicles: repairVehicleTimestamps(
decoded.vehicles,
feed.speedUnit === 'km/h'
? decoded.vehicles.map((v) =>
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
)
: decoded.vehicles,
decoded.timestamp,
Math.floor(now / 1000),
),
};
}
/**
* Fresh window for one feed's cached snapshot: the shared TTL, stretched by a
* registry `refreshMs` for upstreams that rate-limit below one request per
* TTL (data.gov.my allows 4 a minute). Never shorter than the shared TTL.
* @param {{ refreshMs?: number }|null|undefined} feed Registry entry.
* @returns {number}
*/
export function transitFeedTtlMs(feed) {
const refresh = feed?.refreshMs;
return Number.isFinite(refresh) && refresh > TRANSIT_PROXY_TTL_MS
? refresh
: TRANSIT_PROXY_TTL_MS;
}
/**
* Classify a cache entry for the request policy.
* @param {{ at: number }|null|undefined} entry Cached snapshot (`at` = fetch ms).
* @param {number} now
* @param {number} [ttlMs] Fresh window; `transitFeedTtlMs(feed)` for a feed.
* @returns {'none'|'fresh'|'stale'|'expired'}
*/
export function transitCacheState(entry, now) {
export function transitCacheState(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) {
if (!entry || !Number.isFinite(entry.at)) return 'none';
const age = now - entry.at;
if (age < 0) return 'fresh';
if (age < TRANSIT_PROXY_TTL_MS) return 'fresh';
if (age < ttlMs) return 'fresh';
if (age < TRANSIT_PROXY_STALE_MAX_MS) return 'stale';
return 'expired';
}
+43
View File
@@ -12,6 +12,7 @@ import {
repairVehicleTimestamps,
resolveTransitRoute,
transitCacheState,
transitFeedTtlMs,
transitRedirectDecision,
transitResponseHeaders,
transitUpstreamHeaders,
@@ -294,6 +295,27 @@ test('Rio speeds pass through as the m/s they are published in', () => {
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => {
// 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement.
const bytes = encodeFeed({
entities: [
{ id: 'moving', lat: 38.72, lon: -9.14, speed: 36 },
{ id: 'parked', lat: 38.73, lon: -9.15 },
],
});
const feed = getTransitFeed('carris-metropolitana-lisboa');
const lisbon = buildTransitSnapshot(feed, bytes, 1);
const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v]));
assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6);
assert.equal(byId.parked.speedMps, null, 'no speed stays no speed');
const szczecin = buildTransitSnapshot(
getTransitFeed('zditm-szczecin'),
encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }),
1,
);
assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched');
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
@@ -375,3 +397,24 @@ test('client history validates response bounds, epochs and cancellation through
{ name: 'AbortError' },
);
});
test('Adelaide asks for */* because a protobuf Accept gets base64 text back', () => {
const headers = transitUpstreamHeaders(getTransitFeed('adelaide-metro'));
assert.equal(headers.Accept, '*/*');
// Every other feed keeps the protobuf-first Accept.
assert.match(
transitUpstreamHeaders(getTransitFeed('mbta')).Accept,
/protobuf/,
);
});
test('a feed refreshMs stretches its fresh window, never shortens it', () => {
const johor = getTransitFeed('mybas-johor');
assert.equal(transitFeedTtlMs(johor), 30_000);
assert.equal(transitFeedTtlMs(getTransitFeed('mbta')), TRANSIT_PROXY_TTL_MS);
assert.equal(transitFeedTtlMs({ refreshMs: 1_000 }), TRANSIT_PROXY_TTL_MS);
const now = 1_000_000;
const entry = { at: now - 20_000 };
assert.equal(transitCacheState(entry, now), 'stale');
assert.equal(transitCacheState(entry, now, transitFeedTtlMs(johor)), 'fresh');
});
+1
View File
@@ -143,6 +143,7 @@ for (const [locale, summary] of [
['ja', '東京上空のLive Flights'],
['zh-CN', '北京航班stale数据'],
['ru', 'Остин Live-Flights частично'],
['he', 'תל אביב stale טיסות'],
]) {
test(`English flush against other text is an echo: ${summary}`, () => {
assert.ok(hudSummaryHasEnglish(summary, locale, ['stale', 'Live Flights']));
+1 -1
View File
@@ -110,7 +110,7 @@ const SUMMARY_LANGUAGES = Object.freeze({
const UNSPACED_LOCALES = /^(?:ja|zh)(?:-|$)/;
/** Shipped locales not written in Latin letters. */
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh)(?:-|$)/;
const NON_LATIN_LOCALES = /^(?:ar|ja|ru|uk|zh|he)(?:-|$)/;
/** Scripts whose letters extend a word; any other neighbour ends it. */
const WORD_SCRIPTS = [
+6
View File
@@ -41,6 +41,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale('de'), 'de');
assert.equal(normalizeLocale('de-AT'), 'de');
assert.equal(normalizeLocale('de_CH'), 'de');
// Hebrew keeps Western digits like its catalog; regional tags fold to he,
// and the legacy tag iw is still what some browsers and OSes label Hebrew.
assert.equal(normalizeLocale('he'), 'he');
assert.equal(normalizeLocale('he-IL'), 'he');
assert.equal(normalizeLocale('iw'), 'he');
assert.equal(normalizeLocale('iw-IL'), 'he');
assert.equal(normalizeLocale('fr'), 'fr');
assert.equal(normalizeLocale('fr-CA'), 'fr');
assert.equal(normalizeLocale('FR_be'), 'fr');
+2
View File
@@ -22,6 +22,7 @@ import ar from './locales/ar.json' with { type: 'json' };
import de from './locales/de.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import fr from './locales/fr.json' with { type: 'json' };
import he from './locales/he.json' with { type: 'json' };
import it from './locales/it.json' with { type: 'json' };
import ja from './locales/ja.json' with { type: 'json' };
import pt from './locales/pt.json' with { type: 'json' };
@@ -35,6 +36,7 @@ const CATALOGS = Object.freeze({
de,
es,
fr,
he,
it,
ja,
pt,
+6 -2
View File
@@ -14,6 +14,7 @@ export const LOCALES = Object.freeze([
'de',
'es',
'fr',
'he',
'it',
'ja',
'pt',
@@ -30,6 +31,7 @@ export const LOCALE_NAMES = Object.freeze({
de: 'Deutsch',
es: 'Español',
fr: 'Français',
he: 'עברית',
it: 'Italiano',
ja: '日本語',
pt: 'Português (Brasil)',
@@ -40,7 +42,7 @@ export const LOCALE_NAMES = Object.freeze({
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze(['ar']);
const RTL_LOCALES = Object.freeze(['ar', 'he']);
/** Whether a shipped locale reads right to left. */
export function isRtlLocale(locale) {
@@ -62,7 +64,8 @@ export function intlLocale(locale) {
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'ar-EG' and 'ar-SA' → 'ar',
* 'ar-EG' and 'ar-SA' → 'ar', 'he-IL' and 'he' → 'he', and the
* legacy tag 'iw'/'iw-IL' → 'he',
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
@@ -78,6 +81,7 @@ export function normalizeLocale(candidate) {
? 'zh-Hant'
: 'zh-CN';
}
if (primary === 'iw') return 'he';
return LOCALES.includes(primary) ? primary : null;
}
+13 -1
View File
@@ -615,6 +615,7 @@
"LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
"SIGNAL LOST · {time}": "انقطعت الإشارة · {time}",
"FALLBACK": "بديل",
"PARTIAL": "جزئي",
"UNCERTAIN": "غير مؤكد",
@@ -879,6 +880,7 @@
"KEY REQUIRED": "المفتاح مطلوب",
"STALE · cached {age}": "متقادم · مخزّن {age}",
"LIVE · updated {age}": "مباشر · حُدّث {age}",
"24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC",
"<1m ago": "قبل أقل من دقيقة",
"FIRE · {frp} MW": "حريق · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
@@ -3005,6 +3007,15 @@
"many": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة",
"other": "لا مصدر بيانات هنا بعد · {n} منطقة متاحة"
},
"Fly below {km} km to a covered city": "انخفض إلى ما دون {km} كم فوق مدينة مغطاة",
"No bike share here yet · {n} systems available": {
"zero": "لا دراجات مشتركة هنا بعد · {n} نظام متاح",
"one": "لا دراجات مشتركة هنا بعد · نظام واحد متاح ({n})",
"two": "لا دراجات مشتركة هنا بعد · نظامان متاحان ({n})",
"few": "لا دراجات مشتركة هنا بعد · {n} أنظمة متاحة",
"many": "لا دراجات مشتركة هنا بعد · {n} نظامًا متاحًا",
"other": "لا دراجات مشتركة هنا بعد · {n} نظام متاح"
},
"degraded": "متدهور",
"stale": "متقادم",
"partial": "جزئي",
@@ -3610,5 +3621,6 @@
"1 wk/s": "أسبوع/ث",
"Play speed": "سرعة التشغيل",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
"1 min/s": "دقيقة/ث"
"1 min/s": "دقيقة/ث",
"Photo © {photographer}": "صورة © {photographer}"
}
+9 -1
View File
@@ -72,6 +72,7 @@
"LOADING": "LÄDT",
"DEGRADED": "EINGESCHRÄNKT",
"STALE": "VERALTET",
"SIGNAL LOST · {time}": "SIGNAL VERLOREN · {time}",
"PARTIAL": "TEILWEISE",
"FALLBACK": "FALLBACK",
"SUMMARY": "ÜBERSICHT",
@@ -164,6 +165,7 @@
"loading...": "lädt...",
"KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH",
"LIVE · updated {age}": "LIVE · aktualisiert {age}",
"24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC",
"loading mapped installation context": "Kontext der kartierten Anlage wird geladen",
"MAPPED INSTALLATION": "KARTIERTE ANLAGE",
"LAUNCH SITE · {site}": "STARTPLATZ · {site}",
@@ -2751,6 +2753,11 @@
"one": "Hier noch kein Feed · {n} Region verfügbar",
"other": "Hier noch kein Feed · {n} Regionen verfügbar"
},
"Fly below {km} km to a covered city": "Unter {km} km Höhe zu einer abgedeckten Stadt fliegen",
"No bike share here yet · {n} systems available": {
"one": "Hier noch kein Bikesharing · {n} System verfügbar",
"other": "Hier noch kein Bikesharing · {n} Systeme verfügbar"
},
"degraded": "eingeschränkt",
"stale": "veraltet",
"partial": "teilweise",
@@ -3306,5 +3313,6 @@
"1 wk/s": "1 Wo./s",
"Play speed": "Wiedergabegeschwindigkeit",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
"1 min/s": "1 Min./s"
"1 min/s": "1 Min./s",
"Photo © {photographer}": "Foto © {photographer}"
}
+10 -1
View File
@@ -500,6 +500,7 @@
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
"FALLBACK": "RESPALDO",
"PARTIAL": "PARCIAL",
"UNCERTAIN": "INCIERTO",
@@ -737,6 +738,7 @@
"KEY REQUIRED": "CLAVE REQUERIDA",
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
"LIVE · updated {age}": "EN VIVO · actualizado {age}",
"24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC",
"<1m ago": "hace <1 min",
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
@@ -2915,6 +2917,12 @@
"many": "Aún no hay fuente aquí · {n} de regiones disponibles",
"other": "Aún no hay fuente aquí · {n} regiones disponibles"
},
"Fly below {km} km to a covered city": "Desciende por debajo de {km} km sobre una ciudad con cobertura",
"No bike share here yet · {n} systems available": {
"one": "Aún no hay bicis compartidas aquí · {n} sistema disponible",
"many": "Aún no hay bicis compartidas aquí · {n} de sistemas disponibles",
"other": "Aún no hay bicis compartidas aquí · {n} sistemas disponibles"
},
"Wind speed field unavailable": "Campo de velocidad del viento no disponible",
"Decoder feeds": "Fuentes del decodificador",
"WebUSB + decoder feeds": "WebUSB + fuentes del decodificador",
@@ -3382,5 +3390,6 @@
"1 wk/s": "1 sem/s",
"Play speed": "Velocidad de reproducción",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
"1 min/s": "1 min/s"
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
}
+10 -1
View File
@@ -329,6 +329,7 @@
"LOADING": "CHARGEMENT",
"DEGRADED": "DÉGRADÉ",
"STALE": "OBSOLÈTE",
"SIGNAL LOST · {time}": "SIGNAL PERDU · {time}",
"PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS",
"view band|STREET": "RUE",
@@ -734,6 +735,7 @@
"loading...": "chargement…",
"KEY REQUIRED": "CLÉ REQUISE",
"LIVE · updated {age}": "DIRECT · mis à jour {age}",
"24 h to {day} {time} UTC": "24 h jusqu'au {day} {time} UTC",
"Source snapshot time unavailable": "Heure de l'instantané source indisponible",
"Live data unavailable": "Données en direct indisponibles",
"Intel Picture": "Situation renseignement",
@@ -2584,6 +2586,12 @@
"many": "Pas encore de flux ici · {n} de régions disponibles",
"other": "Pas encore de flux ici · {n} régions disponibles"
},
"Fly below {km} km to a covered city": "Descendez sous {km} km au-dessus d'une ville couverte",
"No bike share here yet · {n} systems available": {
"one": "Pas encore de vélos partagés ici · {n} système disponible",
"many": "Pas encore de vélos partagés ici · {n} de systèmes disponibles",
"other": "Pas encore de vélos partagés ici · {n} systèmes disponibles"
},
"degraded": "dégradé",
"stale": "obsolète",
"partial": "partiel",
@@ -3382,5 +3390,6 @@
"1 wk/s": "1 sem/s",
"Play speed": "Vitesse de lecture",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
"1 min/s": "1 min/s"
"1 min/s": "1 min/s",
"Photo © {photographer}": "Photo © {photographer}"
}
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -329,6 +329,7 @@
"LOADING": "CARICAMENTO",
"DEGRADED": "DEGRADATO",
"STALE": "OBSOLETO",
"SIGNAL LOST · {time}": "SEGNALE PERSO · {time}",
"PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA",
"view band|STREET": "STRADA",
@@ -734,6 +735,7 @@
"loading...": "caricamento...",
"KEY REQUIRED": "CHIAVE RICHIESTA",
"LIVE · updated {age}": "LIVE · aggiornato {age}",
"24 h to {day} {time} UTC": "24 h fino al {day} {time} UTC",
"Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile",
"Live data unavailable": "Dati live non disponibili",
"Intel Picture": "Quadro intelligence",
@@ -2584,6 +2586,12 @@
"many": "Ancora nessun feed qui · {n} di regioni disponibili",
"other": "Ancora nessun feed qui · {n} regioni disponibili"
},
"Fly below {km} km to a covered city": "Scendi sotto {km} km su una città coperta",
"No bike share here yet · {n} systems available": {
"one": "Ancora nessun bike sharing qui · {n} sistema disponibile",
"many": "Ancora nessun bike sharing qui · {n} di sistemi disponibili",
"other": "Ancora nessun bike sharing qui · {n} sistemi disponibili"
},
"degraded": "degradato",
"stale": "obsoleto",
"partial": "parziale",
@@ -3382,5 +3390,6 @@
"1 wk/s": "1 sett/s",
"Play speed": "Velocità di riproduzione",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
"1 min/s": "1 min/s"
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
}
+8 -1
View File
@@ -323,6 +323,7 @@
"LOADING": "読込中",
"DEGRADED": "性能低下",
"STALE": "更新遅延",
"SIGNAL LOST · {time}": "信号途絶 · {time}",
"PARTIAL": "部分的",
"FALLBACK": "フォールバック",
"view band|STREET": "街路",
@@ -716,6 +717,7 @@
"loading...": "読込中…",
"KEY REQUIRED": "キーが必要",
"LIVE · updated {age}": "ライブ · 更新 {age}",
"24 h to {day} {time} UTC": "{day} {time} UTC までの24時間",
"Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません",
"Live data unavailable": "ライブデータを利用できません",
"Intel Picture": "情勢図",
@@ -2454,6 +2456,10 @@
"No feed here yet · {n} regions available": {
"other": "ここにはまだフィードがありません · 利用可能な地域 {n}か所"
},
"Fly below {km} km to a covered city": "{km} km未満まで高度を下げ、カバー範囲内の都市へ移動してください",
"No bike share here yet · {n} systems available": {
"other": "ここにはまだシェアサイクルがありません · 利用可能なシステム {n}件"
},
"degraded": "性能低下",
"stale": "更新遅延",
"partial": "部分的",
@@ -3230,5 +3236,6 @@
"1 wk/s": "1週間/秒",
"Play speed": "再生速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
"1 min/s": "1分/秒"
"1 min/s": "1分/秒",
"Photo © {photographer}": "写真 © {photographer}"
}
+10 -1
View File
@@ -329,6 +329,7 @@
"LOADING": "CARREGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESATUALIZADO",
"SIGNAL LOST · {time}": "SINAL PERDIDO · {time}",
"PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO",
"view band|STREET": "RUA",
@@ -732,6 +733,7 @@
"loading...": "carregando...",
"KEY REQUIRED": "CHAVE NECESSÁRIA",
"LIVE · updated {age}": "AO VIVO · atualizado {age}",
"24 h to {day} {time} UTC": "24 h até {day} {time} UTC",
"Source snapshot time unavailable": "Horário do snapshot da fonte indisponível",
"Live data unavailable": "Dados ao vivo indisponíveis",
"Intel Picture": "Panorama de inteligência",
@@ -2586,6 +2588,12 @@
"many": "Ainda não há feed aqui · {n} de regiões disponíveis",
"other": "Ainda não há feed aqui · {n} regiões disponíveis"
},
"Fly below {km} km to a covered city": "Desça abaixo de {km} km sobre uma cidade com cobertura",
"No bike share here yet · {n} systems available": {
"one": "Ainda não há bicicletas compartilhadas aqui · {n} sistema disponível",
"many": "Ainda não há bicicletas compartilhadas aqui · {n} de sistemas disponíveis",
"other": "Ainda não há bicicletas compartilhadas aqui · {n} sistemas disponíveis"
},
"degraded": "degradado",
"stale": "desatualizado",
"partial": "parcial",
@@ -3382,5 +3390,6 @@
"1 wk/s": "1 sem/s",
"Play speed": "Velocidade de reprodução",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
"1 min/s": "1 min/s"
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
}
+11 -1
View File
@@ -143,10 +143,12 @@
"{count}h ago": "{count} ч назад",
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
"STALE": "УСТАРЕЛ",
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
"refreshing...": "обновление...",
"loading...": "загрузка...",
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · обновлено {age}",
"24 h to {day} {time} UTC": "24 ч до {day} {time} UTC",
"Retrying mapped sites…": "Повторный запрос объектов…",
"Fetching mapped sites…": "Загрузка объектов…",
"Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты",
@@ -2979,6 +2981,13 @@
"many": "Здесь пока нет потока данных · доступно {n} регионов",
"other": "Здесь пока нет потока данных · доступно {n} региона"
},
"Fly below {km} km to a covered city": "Опуститесь ниже {km} км над городом с покрытием",
"No bike share here yet · {n} systems available": {
"one": "Здесь пока нет проката велосипедов · доступна {n} система",
"few": "Здесь пока нет проката велосипедов · доступны {n} системы",
"many": "Здесь пока нет проката велосипедов · доступно {n} систем",
"other": "Здесь пока нет проката велосипедов · доступно {n} системы"
},
"Wind speed field unavailable": "Поле скорости ветра недоступно",
"Decoder feeds": "Потоки декодера",
"WebUSB + decoder feeds": "WebUSB + потоки декодера",
@@ -3458,5 +3467,6 @@
"1 wk/s": "1 нед/с",
"Play speed": "Скорость воспроизведения",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
"1 min/s": "1 мин/с"
"1 min/s": "1 мин/с",
"Photo © {photographer}": "Фото © {photographer}"
}
+11 -1
View File
@@ -332,6 +332,7 @@
"LOADING": "ЗАВАНТАЖЕННЯ",
"DEGRADED": "ПЕРЕБОЇ",
"STALE": "ЗАСТАРІЛО",
"SIGNAL LOST · {time}": "СИГНАЛ ВТРАЧЕНО · {time}",
"PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ",
"view band|STREET": "ВУЛИЦЯ",
@@ -742,6 +743,7 @@
"loading...": "завантаження...",
"KEY REQUIRED": "ПОТРІБЕН КЛЮЧ",
"LIVE · updated {age}": "ОНЛАЙН · оновлено {age}",
"24 h to {day} {time} UTC": "24 год до {day} {time} UTC",
"Source snapshot time unavailable": "Час знімка джерела невідомий",
"Live data unavailable": "Дані в реальному часі недоступні",
"Intel Picture": "Розвідувальна обстановка",
@@ -2648,6 +2650,13 @@
"many": "Тут поки немає потоку даних · доступно {n} регіонів",
"other": "Тут поки немає потоку даних · доступно {n} регіону"
},
"Fly below {km} km to a covered city": "Опустіться нижче {km} км над містом із покриттям",
"No bike share here yet · {n} systems available": {
"one": "Тут поки немає прокату велосипедів · доступна {n} система",
"few": "Тут поки немає прокату велосипедів · доступні {n} системи",
"many": "Тут поки немає прокату велосипедів · доступно {n} систем",
"other": "Тут поки немає прокату велосипедів · доступно {n} системи"
},
"degraded": "з перебоями",
"stale": "застарів",
"partial": "частково",
@@ -3458,5 +3467,6 @@
"1 wk/s": "1 тиж/с",
"Play speed": "Швидкість відтворення",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
"1 min/s": "1 хв/с"
"1 min/s": "1 хв/с",
"Photo © {photographer}": "Фото © {photographer}"
}
+8 -1
View File
@@ -323,6 +323,7 @@
"LOADING": "加载中",
"DEGRADED": "降级",
"STALE": "过期",
"SIGNAL LOST · {time}": "信号丢失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "备用",
"view band|STREET": "街道",
@@ -716,6 +717,7 @@
"loading...": "加载中…",
"KEY REQUIRED": "需要密钥",
"LIVE · updated {age}": "实时 · 更新于 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时",
"Source snapshot time unavailable": "来源快照时间不可用",
"Live data unavailable": "实时数据不可用",
"Low-accuracy score < 2%": "低精度评分 < 2%",
@@ -2355,6 +2357,10 @@
"No feed here yet · {n} regions available": {
"other": "此处暂无数据源 · 有 {n} 个覆盖区域"
},
"Fly below {km} km to a covered city": "请降至 {km} km 以下并飞往有覆盖的城市",
"No bike share here yet · {n} systems available": {
"other": "此处暂无共享单车 · 有 {n} 个可用系统"
},
"degraded": "降级",
"stale": "过期",
"partial": "部分可用",
@@ -3230,5 +3236,6 @@
"1 wk/s": "1 周/秒",
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
"1 min/s": "1 分钟/秒"
"1 min/s": "1 分钟/秒",
"Photo © {photographer}": "照片 © {photographer}"
}
+8 -1
View File
@@ -323,6 +323,7 @@
"LOADING": "加載中",
"DEGRADED": "降級",
"STALE": "過期",
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "備用",
"view band|STREET": "街道",
@@ -711,6 +712,7 @@
"loading...": "加載中…",
"KEY REQUIRED": "需要密鑰",
"LIVE · updated {age}": "實時 · 更新於 {age}",
"24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時",
"Source snapshot time unavailable": "來源快照時間不可用",
"Live data unavailable": "實時數據不可用",
"Low-accuracy score < 2%": "低精度評分 < 2%",
@@ -2343,6 +2345,10 @@
"No feed here yet · {n} regions available": {
"other": "此處暫無數據源 · 有 {n} 個覆蓋區域"
},
"Fly below {km} km to a covered city": "請降至 {km} km 以下並飛往有覆蓋的城市",
"No bike share here yet · {n} systems available": {
"other": "此處暫無共享單車 · 有 {n} 個可用系統"
},
"degraded": "降級",
"stale": "過期",
"partial": "部分可用",
@@ -3230,5 +3236,6 @@
"1 wk/s": "1 週/秒",
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
"1 min/s": "1 分鐘/秒"
"1 min/s": "1 分鐘/秒",
"Photo © {photographer}": "照片 © {photographer}"
}
+99 -1
View File
@@ -586,7 +586,8 @@
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW",
"SATELLITE"
"SATELLITE",
"Photo © {photographer}"
],
"it": [
"1 min/s",
@@ -1189,5 +1190,102 @@
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
],
"he": [
"{speed} kn",
"GEO",
"SAR",
"EARTH WATCH",
"CRT",
"NVG",
"FLIR",
"AIS: --",
"REC",
"HDG {value}°",
"BRG {value}°",
"BRG —",
"CRS {value}°",
"FOV {value}°",
"CAL {value}",
"SRC {value}",
"▲ {frp} MW",
"≥ {value} MW",
"Intel Picture",
"Kinetic 30d",
"Border Crisis",
"Iran Picture",
"Iran Kinetic",
"Yemen FLOT",
"Latest Drop",
"1090 MHz",
"978 MHz UAT",
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
"V/S {rate} FPM",
"Sentinel-2",
"Landsat 8/9",
"Google Street View",
"GOOGLE STREET VIEW",
"AIS: {name}",
"{type} SPD: {speed} HDG: {heading}",
"POS: {time}Z",
"{source} HTTP {status}",
"NNE",
"NE",
"ENE",
"ESE",
"SE",
"SSE",
"SSW",
"SW",
"WSW",
"WNW",
"NW",
"NNW",
"WHOT/BHOT",
"HDG",
"FOV",
"HGT",
"ΔN",
"ΔE",
"CAL · {badge}",
"DEST ---°",
"SQUAWK {code} · {status}",
"DEST {bearing}",
"CCTV",
"S2 GhostMaps",
"Street Level",
"STREET LEVEL",
"km/h",
"mph",
"hPa",
"kt",
"kts",
"ft",
"km",
"min",
"dB",
"KTS",
"FT",
"FLT",
"MIL",
"WX",
"MSG/S",
"FM MHZ",
"Acartia",
"CC BY 4.0",
"Salish Sea",
"iNaturalist",
"Be Whale Wise",
"MIC",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"{range} NM",
"NORAD {id}",
"EPOCH {time}",
"TLE",
"{value} KM",
"{value} KM/S",
"Ironbow"
]
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { normalizeDisplayText } from '../../data/displayText.js';
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
function textTag(value) {
const t = String(value ?? '').trim();
const t = normalizeDisplayText(value);
return t || null;
}
+23
View File
@@ -182,6 +182,28 @@ export function createPanel({ state: layerState, parts }) {
: null;
}
/** The tracked airliner's photo with its credit, or null. The subject
* refresh resolves it; only the subject it was resolved for shows it. */
function presentPhoto(subject) {
const held = layerState.subjectPhoto;
if (
!held ||
held.layerId !== subject.layerId ||
String(held.id) !== String(subject.id)
)
return null;
const { photo } = held;
return {
src: photo.src,
width: photo.width ?? null,
height: photo.height ?? null,
link: photo.link,
credit: t('Photo © {photographer}', {
photographer: photo.photographer,
}),
};
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
ensurePanel();
@@ -191,6 +213,7 @@ export function createPanel({ state: layerState, parts }) {
kind: 'results',
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
subjectNames: presentNames(subject.memberNames),
photo: presentPhoto(subject),
navigation: presentNavigation(),
rows: cohorts.map(presentRow),
note: t(
+117
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
import { createSubject } from './subject.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
@@ -36,3 +37,119 @@ test('a Contacts row names its provider as the Layers panel does, with its feed
);
state.offPanelLocale?.();
});
const PHOTO = Object.freeze({
src: 'https://t.plnspttrs.net/1/abc123_280.jpg',
width: 280,
height: 186,
photographer: 'Jane Doe',
link: 'https://www.planespotters.net/photo/1/n123ab',
});
/** The published Contacts view for one subject and the refresh's photo. */
function photoView(subject, subjectPhoto) {
const state = {
enabled: true,
results: { subject, cohorts: [] },
subjectPhoto,
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
state.offPanelLocale?.();
return state.panelView;
}
const FLIGHT = Object.freeze({
layerId: 'flights',
id: 'abc123',
label: 'N123AB',
});
test('the Contacts view carries the tracked flight’s photo with its credit', () => {
const view = photoView(FLIGHT, {
layerId: 'flights',
id: 'abc123',
photo: PHOTO,
});
assert.deepEqual(view.photo, {
src: PHOTO.src,
width: 280,
height: 186,
link: PHOTO.link,
credit: 'Photo © Jane Doe',
});
});
test('the Contacts view has no photo without one, or with another subject’s', () => {
assert.equal(photoView(FLIGHT, null).photo, null);
assert.equal(
photoView(FLIGHT, { layerId: 'flights', id: 'def456', photo: PHOTO }).photo,
null,
);
assert.equal(
photoView(
{ layerId: 'ais-live-vessels', id: 'abc123', label: 'VESSEL' },
{ layerId: 'flights', id: 'abc123', photo: PHOTO },
).photo,
null,
);
});
test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => {
const tracked = {
layerId: 'flights',
id: 'abc123',
label: 'N123AB',
photo: PHOTO,
};
let current = tracked;
let lookups = 0;
const flights = {
getTrackedSubject: () => {
lookups += 1;
return current;
},
};
const military = {
getTrackedSubject: () => ({ ...tracked, layerId: 'military' }),
};
const { resolveSubjectPhoto } = createSubject({
state: {},
services: { flights, military, vessels: {}, installations: {} },
parts: {
navigation: {
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
},
});
assert.deepEqual(
resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }),
PHOTO,
);
assert.equal(lookups, 1);
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'def456' }), null);
assert.equal(
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'abc123' }),
null,
);
assert.equal(
resolveSubjectPhoto({ layerId: 'military-installations', id: 'abc123' }),
null,
);
assert.equal(
resolveSubjectPhoto({ layerId: 'military', id: 'abc123' }),
null,
);
assert.equal(resolveSubjectPhoto(null), null);
current = null;
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
current = { ...tracked, photo: null };
assert.equal(resolveSubjectPhoto({ layerId: 'flights', id: 'abc123' }), null);
});
+2
View File
@@ -29,6 +29,8 @@ export function createState({ services }) {
/** Stops following the manager's adoptions (see watchAdoptions). */
unwatchAdoptions: null,
subject: null,
/** The tracked flight's photo with the subject it belongs to, or null. */
subjectPhoto: null,
results: null,
visual: null,
/** Whether layer views replace the panel's initial markup yet. */
+52 -8
View File
@@ -378,19 +378,48 @@ export function createSubject({ state: layerState, services, parts, source }) {
*
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
* @param {{layerId: string, id: string, label: string}} subject Current subject.
* @param {?object} [tracked] The subject's tracked descriptor, when the
* caller already has it (see trackedSubjectOf).
* @returns {string} The current label, or the cached one when the owning layer
* has no tracked subject (e.g. a cross-layer handoff is in flight).
*/
function resolveSubjectLabel(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId))
return subject?.label;
function resolveSubjectLabel(subject, tracked = trackedSubjectOf(subject)) {
return tracked?.label || subject?.label;
}
/**
* The owning flight layer's tracked-subject descriptor when it describes
* this subject, else null. Built once per refresh and shared by the label
* and the photo.
* @param {{layerId: string, id: string}|null} subject Current subject.
* @returns {?{id: string, label: string, photo?: ?object}}
*/
function trackedSubjectOf(subject) {
if (!parts.navigation.isFlightLayer(subject?.layerId)) return null;
const layer =
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
const tracked = layer.getTrackedSubject?.();
if (tracked?.label && String(tracked.id) === String(subject.id))
return tracked.label;
return subject.label;
return tracked && String(tracked.id) === String(subject.id)
? tracked
: null;
}
/**
* The tracked airliner's Planespotters photo, for the Contacts panel.
*
* Only the flights layer looks photos up, and only for the plane it tracks;
* a vessel, an installation, a military aircraft or an untracked flight has
* none.
* @param {{layerId: string, id: string}|null} subject Current subject.
* @param {?object} [tracked] The subject's tracked descriptor, when the
* caller already has it.
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}}
*/
function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) {
return subject?.layerId === 'flights' && tracked?.photo
? tracked.photo
: null;
}
/** Refresh proximity counts/distances against the subject's current live position. */
@@ -417,8 +446,21 @@ export function createSubject({ state: layerState, services, parts, source }) {
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
layerState.subjectMissing = true;
const nextLabel = resolveSubjectLabel(layerState.subject);
const tracked = trackedSubjectOf(layerState.subject);
const nextLabel = resolveSubjectLabel(layerState.subject, tracked);
const labelChanged = nextLabel !== layerState.subject.label;
// The panel reads the photo from here, with the subject it belongs to.
const photo = resolveSubjectPhoto(layerState.subject, tracked);
layerState.subjectPhoto = photo
? {
layerId: layerState.subject.layerId,
id: layerState.subject.id,
photo,
}
: null;
// A photo arriving after selection is render-worthy on its own, as a label.
const photoChanged =
(photo?.src ?? null) !== (layerState.panelView?.photo?.src ?? null);
layerState.subject = { ...layerState.subject, position, label: nextLabel };
const movementM = layerState.lastEvaluatedPosition
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
@@ -439,7 +481,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
// kept the ICAO hex until unrelated movement, a source-revision bump, or
// page rotation happened to repaint it. Gated on an ACTUAL change so the
// common no-op refresh (every 750 ms) still costs no repaint.
if (labelChanged) parts.panel.renderResults();
if (labelChanged || photoChanged) parts.panel.renderResults();
parts.rendering.scheduleDirectionOverlayUpdate(force);
return;
}
@@ -502,6 +544,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
militaryInstallationsLayer.setContextAnchor?.(null);
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectPhoto = null;
layerState.subjectMissing = false;
layerState.results = null;
layerState.navigationHistory = [];
@@ -528,6 +571,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
resolveSubjectPosition,
collectionSubjectPosition,
resolveSubjectLabel,
resolveSubjectPhoto,
refreshSelectedSubject,
subjectFromContext,
markSubjectEvicted,
+37 -4
View File
@@ -1,5 +1,6 @@
import { msg, t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
import { ACTIVATION_ENTER_ALTITUDE_M, STATUS_POLL_MS } from './policy.js';
import { GBFS_CITY_REGISTRY } from './registry.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -13,6 +14,14 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: STATUS_POLL_MS,
/**
* Kept so camera moves can repaint the row before the next status poll.
* @param {object} dataManager DataLayerManager instance.
*/
attachDataManager(dataManager) {
layerState._dataManager = dataManager;
},
/**
* Return a sampled array of detectable station objects for HUD overlay rendering.
* @param {Object} [options] - Sampling options (maxCount, seed).
@@ -57,15 +66,39 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean, status?: string, statusMessage?: string }}
*/
getStats() {
// A city in range without points is loading even before its fetch
// starts: the row repaints on entry, ahead of beginLoading().
const loading =
layerState._loading ||
[...layerState._activeCityIds].some(
(cityId) =>
!layerState._cityRuntime.has(cityId) &&
!layerState._failedCityIds.has(cityId),
);
const stats = {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
loading,
};
if (layerState._loading) {
// No system in range: the camera is too high or no system is near. A
// failed city also leaves range empty and reports its failure below.
if (
layerState._enabled &&
!loading &&
layerState._activeCityIds.size === 0 &&
layerState._failedCityIds.size === 0
) {
// English: the analyst's empty note and the row's prompt, both shown
// through translateStatusMessage (ui/statusText.js).
const note = layerState._altitudeGateEnabled
? `No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`
: `Fly below ${Math.round(ACTIVATION_ENTER_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered city`;
return { ...stats, status: 'zoom-in', statusMessage: note };
}
if (loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? t('syncing {count} city feeds...', {
+1
View File
@@ -169,6 +169,7 @@ export function createLifecycle({
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
layerState._dataManager = null;
layerState._viewer = null;
},
};
+366
View File
@@ -0,0 +1,366 @@
// Bike Share's Layers row. With no system in range it says why instead of
// "never" and keeps no date for data it no longer draws. After the camera
// settles, the last row the panel painted must match what the layer reports
// now. Each case drives the real viewport,
// ingestion, rendering and controls; the manager is a spy that records the
// row text (LayerPanel._buildMetaText) at every refreshLayerStats() call.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { CITY_BY_ID, GBFS_CITY_REGISTRY } from './registry.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { LayerPanel, layerFeedState } from '../../ui/layerPanel.js';
const BOSTON_ID = 'boston-bluebikes';
const TORONTO_ID = 'toronto-bike-share';
const BUFFALO_ID = 'buffalo-reddy';
const LONDON = { lat: 51.5072, lon: -0.1276 };
const BOSTON = { lat: 42.3601, lon: -71.0589 };
const BOSTON_NUDGED = { lat: 42.37, lon: -71.05 };
const BETWEEN = { lat: 43.25, lon: -79.1 };
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
const LOW_M = 10_000;
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
function makeSource() {
const cityOf = (url) => {
for (const [id, city] of CITY_BY_ID) {
if (url === city.stationInformationUrl) return { id, kind: 'info' };
if (url === city.stationStatusUrl) return { id, kind: 'status' };
}
throw new Error(`unexpected GBFS url ${url}`);
};
const source = {
dead: new Set(),
/** `${cityId}:${kind}` → deferred that gates the answer. */
gates: new Map(),
async getStations(url, { signal } = {}) {
const { id, kind } = cityOf(url);
const gate = source.gates.get(`${id}:${kind}`);
if (gate) {
await new Promise((resolve, reject) => {
gate.promise.then(resolve);
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
});
} else {
await new Promise((resolve) => setTimeout(resolve, 1));
}
signal?.throwIfAborted();
if (source.dead.has(id)) throw new Error('GBFS HTTP 503');
const city = CITY_BY_ID.get(id);
if (kind === 'info') {
return {
data: {
stations: [
{
station_id: `${id}-1`,
name: `${id} dock`,
lat: city.centerLat,
lon: city.centerLon,
capacity: 10,
},
],
},
};
}
return {
data: {
stations: [
{
station_id: `${id}-1`,
num_bikes_available: 3,
num_docks_available: 7,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
};
},
};
return source;
}
/** The row as the panel draws it: state chip, count, meta text. */
const rowText = (raw) => {
const stats = {
count: 0,
lastUpdate: null,
...raw,
loading: raw.loading === true,
};
const meta = LayerPanel.prototype._buildMetaText({
id: 'bikeshare',
source: 'GBFS',
enabled: true,
lifecycleState: 'enabled',
stats,
});
return `${layerFeedState(stats)} | ${stats.count || '—'} | ${meta}`;
};
function makeLiveLayer() {
const source = makeSource();
const services = {
render: { governorRequestRender() {} },
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.queries = { collectDetectableStations: () => [] };
parts.lifecycle = createLifecycle(context);
const { methods: controls } = createControls(context);
const camera = { ...LONDON, height: LOW_M };
const viewer = {
camera: {
get positionCartographic() {
return Cesium.Cartographic.fromDegrees(
camera.lon,
camera.lat,
camera.height,
);
},
computeViewRectangle: () => undefined,
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: { globe: {}, primitives: { add() {}, remove() {} } },
};
parts.lifecycle.methods.init(viewer);
const painted = [];
controls.attachDataManager({
refreshLayerStats() {
painted.push(rowText(controls.getStats()));
},
});
return {
state,
source,
painted,
lastPainted: () => painted.at(-1),
stats: () => controls.getStats(),
live: () => rowText(controls.getStats()),
moveTo(place, height = LOW_M) {
Object.assign(camera, place, { height });
return parts.viewport.runProximityCheck();
},
async enable() {
parts.lifecycle.methods.enable(viewer);
await settle();
},
};
}
async function settle() {
for (let i = 0; i < 5; i += 1)
await new Promise((resolve) => setTimeout(resolve, 2));
}
test('entering a city paints the loading row, not "never"', async () => {
const layer = makeLiveLayer();
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const before = layer.painted.length;
const entering = layer.moveTo(BOSTON);
assert.ok(layer.painted.length > before, 'entering repainted');
const atEntry = layer.lastPainted();
const liveAfter = layer.live();
info.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('re-entering a city with cached station info paints loading', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
await layer.moveTo(LONDON);
await settle();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
const entering = layer.moveTo(BOSTON);
const atEntry = layer.lastPainted();
await Promise.resolve();
await Promise.resolve();
const liveAfter = layer.live();
status.resolve();
await entering;
await settle();
assert.match(liveAfter, /syncing 1 city feeds/);
assert.match(atEntry, /^loading \|/, 'the entry repaint reads loading');
assert.doesNotMatch(atEntry, /never/);
});
test('leaving a city mid-load does not leave the row on LOADING', async () => {
const layer = makeLiveLayer();
layer.source.gates.set(`${BOSTON_ID}:info`, deferred());
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.equal(layer.state._loading, true);
await layer.moveTo(LONDON);
await entering;
await settle();
assert.equal(layer.state._loading, false);
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('a camera nudge during the first load still repaints the drawn stations', async () => {
const layer = makeLiveLayer();
const status = deferred();
layer.source.gates.set(`${BOSTON_ID}:status`, status);
await layer.enable();
const entering = layer.moveTo(BOSTON);
await settle();
assert.ok(layer.state._cityRuntime.has(BOSTON_ID), 'points are drawn');
await layer.moveTo(BOSTON_NUDGED);
status.resolve();
await entering;
await settle();
assert.equal(layer.state._count, 1, 'one station on the map');
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving a city whose load failed repaints the error away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BOSTON_ID);
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.match(layer.lastPainted(), /GBFS fetch error/i);
await layer.moveTo(LONDON);
await settle();
assert.match(layer.live(), /No bike share here yet/);
assert.equal(layer.lastPainted(), layer.live());
});
test('leaving the failed city of two repaints DEGRADED away', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO_ID);
await layer.enable();
await layer.moveTo(BETWEEN);
await settle();
assert.match(layer.lastPainted(), /^degraded/);
await layer.moveTo(TORONTO_ONLY);
await settle();
assert.doesNotMatch(layer.live(), /^degraded/);
assert.equal(layer.lastPainted(), layer.live());
});
test('above the altitude gate the row says how low to fly', async () => {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(LONDON, 2_000_000);
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(stats.statusMessage, 'Fly below 48 km to a covered city');
assert.equal(stats.error, undefined);
});
test('a city with no system says so and counts the ones there are', async () => {
const layer = makeLiveLayer();
await layer.enable();
const stats = layer.stats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.statusMessage,
`No bike share here yet · ${GBFS_CITY_REGISTRY.length} systems available`,
);
assert.equal(stats.count, 0);
});
test('a city in range, loading, failed or a disabled layer gets no prompt', async () => {
const layer = makeLiveLayer();
assert.equal(layer.stats().status, undefined, 'disabled');
const info = deferred();
layer.source.gates.set(`${BOSTON_ID}:info`, info);
await layer.enable();
const entering = layer.moveTo(BOSTON);
assert.equal(layer.stats().status, undefined, 'loading');
info.resolve();
await entering;
await settle();
assert.equal(layer.stats().status, undefined, 'loaded');
// Left mid-load: range is empty, but the aborted load still owns the row.
const leaving = makeLiveLayer();
leaving.source.gates.set(`${BOSTON_ID}:info`, deferred());
await leaving.enable();
const loading = leaving.moveTo(BOSTON);
await settle();
const left = leaving.moveTo(LONDON);
assert.equal(leaving.state._activeCityIds.size, 0);
assert.equal(leaving.stats().loading, true);
assert.equal(leaving.stats().status, undefined, 'leaving mid-load');
await Promise.all([loading, left]);
const failed = makeLiveLayer();
failed.source.dead.add(BOSTON_ID);
await failed.enable();
await failed.moveTo(BOSTON);
await settle();
assert.equal(failed.stats().status, undefined, 'failed');
assert.equal(failed.stats().error, 'GBFS fetch error');
});
test('leaving every system behind drops the date of the data it drew', async () => {
for (const height of [LOW_M, 2_000_000]) {
const layer = makeLiveLayer();
await layer.enable();
await layer.moveTo(BOSTON);
await settle();
assert.ok(layer.stats().lastUpdate, 'Boston is dated');
await layer.moveTo(LONDON, height);
assert.equal(layer.stats().lastUpdate, null, `at ${height} m`);
}
});
+4
View File
@@ -19,6 +19,10 @@ export function createState({ services }) {
layerState._viewer = null;
/** @type {{ refreshLayerStats?: (layerId?: string) => void }|null} Manager that repaints the layer's row. */
layerState._dataManager = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
+25 -3
View File
@@ -116,6 +116,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
parts.rendering.removeCityPoints(cityId);
}
/** Repaint the row now: it follows the camera, the manager tick is 60 s. */
function refreshRow() {
layerState._dataManager?.refreshLayerStats?.('bikeshare');
}
/** Deactivate all currently active cities and clear the active set. */
function deactivateAllCities() {
@@ -125,6 +130,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
// A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear();
layerState._count = 0;
// Nothing is left on the map to date.
layerState._lastUpdate = null;
}
/**
@@ -203,9 +210,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(layerState._viewer);
const wasGateEnabled = layerState._altitudeGateEnabled;
// Altitude gate: disable all cities when camera is too high
if (!shouldActivateAtAltitude(altitude)) {
deactivateAllCities();
if (wasGateEnabled) refreshRow();
return;
}
@@ -220,15 +229,25 @@ export function createViewport({ state: layerState, services, parts, source }) {
}
}
// A failure never outlives its city's range.
let failureCleared = false;
for (const cityId of layerState._failedCityIds) {
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
if (!nextActive.has(cityId)) {
layerState._failedCityIds.delete(cityId);
failureCleared = true;
}
}
const changed =
!wasGateEnabled ||
failureCleared ||
nextActive.size !== layerState._activeCityIds.size ||
[...nextActive].some((cityId) => !layerState._activeCityIds.has(cityId));
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
if (nextActive.size === 0) {
layerState._count = 0;
return;
layerState._lastUpdate = null;
}
if (changed) refreshRow();
// Only activate cities that don't already have rendered points
const toActivate = [];
@@ -240,6 +259,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
await Promise.all(
toActivate.map((cityId) => activateCity(cityId, generation)),
);
// Superseded loads repaint too: a newer check may have painted the row
// while this one was still loading, or found nothing to load itself.
if (layerState._enabled) refreshRow();
}
/** Schedule a debounced proximity check after camera movement. */
+1 -1
View File
@@ -448,7 +448,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
}
// Photoreal regime. Sampling is delegated to the shared coarse-cell
// sampler. It remains event-driven, one-shot per cell, and keeps its
// sampler. It remains event-driven, a few probes per cell, and keeps its
// existing acceptance window; CCTV adds no rooftop rejection policy.
if (sampleGround && parts.model.projectionTilesReady()) {
record.groundMeshSampleRequestCount =
+8
View File
@@ -3,6 +3,10 @@ import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js';
/** A time a Date can print: epoch ms within JavaScript's date range. */
const isSnapshotEnd = (value) =>
Number.isFinite(value) && Math.abs(value) <= 8_640_000_000_000_000;
export function createIngestion({
layerState,
services,
@@ -32,6 +36,7 @@ export function createIngestion({
return;
if (payload.keyRequired) {
layerState._keyRequired = true;
layerState._at = null;
layerState._error = null;
layerState._stale = false;
return;
@@ -39,6 +44,9 @@ export function createIngestion({
layerState._keyRequired = false;
layerState._error = null;
layerState._stale = Boolean(payload?.stale);
// A snapshot of the 24 hours to a set time names it; one of the
// trailing 24 hours to now does not.
layerState._at = isSnapshotEnd(payload?.at) ? payload.at : null;
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._allFires = adaptFirmsRecords(payload?.fires);
+1
View File
@@ -127,6 +127,7 @@ export function createLifecycle({
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._at = null;
layerState._keyRequired = false;
layerState._stale = false;
layerState._error = null;
+14 -3
View File
@@ -8,7 +8,7 @@ import {
rampTrackGradient,
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
@@ -190,8 +190,9 @@ export function createQueries({
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
* like a healthy empty layer) with a matching human `loadingLabel`:
* 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy
* served past-TTL cache, 'KEY REQUIRED' keyless.
* 'LIVE · updated Xm ago' fresh, '24 h to MM-DD HH:MM UTC' for a
* snapshot that ends at a set time (`at`), 'STALE · cached Xh' when the
* proxy served past-TTL cache, 'KEY REQUIRED' keyless.
*/
getStats() {
const now = Date.now();
@@ -213,6 +214,11 @@ export function createQueries({
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (Number.isFinite(layerState._at)) {
loadingLabel = t('24 h to {day} {time} UTC', {
day: formatUtcDayMonth(layerState._at),
time: new Date(layerState._at).toISOString().slice(11, 16),
});
} else if (layerState._lastUpdate) {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
@@ -226,6 +232,11 @@ export function createQueries({
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
stale: layerState._stale,
// The end of the 24 hours shown when the snapshot ends at a set time;
// null for the trailing 24 hours to now. Such a snapshot is not live,
// so the row names the provider without the catalog's "LIVE".
at: Number.isFinite(layerState._at) ? layerState._at : null,
...(Number.isFinite(layerState._at) ? { source: 'NASA FIRMS' } : {}),
// The machine-readable half of the keyless state, ahead of the human
// strings below: without it "no key configured" is indistinguishable
// from a broken feed, and the row reads as a fault instead of a step
+2
View File
@@ -77,6 +77,8 @@ export function createFirmsState({ services, config }) {
layerState._lastUpdate = null;
layerState._at = null;
layerState._currentLodId = null;
layerState._currentLodIndex = -1;
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js';
import { createQueries } from './queries.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const at = Date.UTC(2026, 9, 21, 15);
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - 120000,
_at: at,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, '24 h to 10-21 15:00 UTC');
assert.equal(methods.getStats().at, at);
assert.equal(methods.getStats().source, 'NASA FIRMS', 'the row drops LIVE');
setLocale('de');
assert.equal(
methods.getStats().loadingLabel,
'24 Std. bis 21.10. 15:00 UTC',
);
layerState._at = null;
setLocale('en');
assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago');
assert.equal(methods.getStats().at, null);
assert.equal(methods.getStats().source, undefined);
} finally {
setLocale(previousLocale);
}
});
+111 -7
View File
@@ -1,5 +1,6 @@
import { classifyAircraft } from '../../data/aircraftClass.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
import * as Cesium from 'cesium';
import {
ENRICH_MAX_INFLIGHT,
@@ -8,6 +9,7 @@ import {
ENRICH_AMBIENT_REFILL_TOKENS,
ENRICH_AMBIENT_REFILL_WINDOW_MS,
ENRICH_AMBIENT_PER_SWEEP,
ENRICH_RETRY_COOLDOWN_MS,
} from './policy.js';
export function createEnrichment({
@@ -17,11 +19,62 @@ export function createEnrichment({
layer,
resolveAsset,
}) {
function _enqueueEnrich(key, query, onData, priority = false) {
const { isMilitaryIcao } = services.militaryRegistry;
const { isTr3b } = services.aircraftPresentation;
/**
* Whether `key` is answered, queued, or cooling down after a failure. A
* key past its cool-down is released here so it can be asked again.
* @param {string} key Lookup key.
* @returns {boolean}
*/
function _enrichHeld(key) {
if (flightState._enrichSeen.has(key)) return true;
const retryAfter = flightState._enrichRetryAfter.get(key);
if (retryAfter === undefined) return false;
if (Date.now() < retryAfter) return true;
flightState._enrichRetryAfter.delete(key);
return false;
}
/**
* Whether a source failure is permanent: an unsupported lookup, or an HTTP
* 4xx other than 401/403 (an authorization refusal that may clear), 408
* (timeout) and 429 (rate limited). Anything else, including an error without a
* status, is temporary.
* @param {*} error The rejection, as `LiveSourceError` / `httpError` carry it.
* @returns {boolean}
*/
function _enrichFailureIsFinal(error) {
if (error?.code === 'unsupported') return true;
const status = Number(error?.status);
return (
Number.isInteger(status) &&
status >= 400 &&
status < 500 &&
status !== 401 &&
status !== 403 &&
status !== 408 &&
status !== 429
);
}
/**
* Ask each key at most once per session, except that a lookup whose source
* failed temporarily (rejected or threw) may be asked again after
* ENRICH_RETRY_COOLDOWN_MS; an answer, found or not, and a permanent
* refusal (see _enrichFailureIsFinal) are final.
* @param {string} key Session-unique lookup key.
* @param {object} query The source's enrichment query.
* @param {function(object):void} onData Receives a `found` answer.
* @param {boolean} [priority] Front of the queue.
* @param {function(*):?object} [read] Reads the source's answer first, for
* a lookup whose answer may arrive in more than one shape.
*/
function _enqueueEnrich(key, query, onData, priority = false, read = null) {
if (flightState.lifetime.signal.aborted) return;
if (flightState._enrichSeen.has(key)) return;
if (_enrichHeld(key)) return;
flightState._enrichSeen.add(key);
const job = { query, onData };
const job = { key, query, onData, read };
// Priority (tracked / model-eligible) goes to the FRONT so a deep ambient
// backlog can never delay the plane the user just clicked or zoomed into.
if (priority) flightState._enrichQueue.unshift(job);
@@ -61,9 +114,24 @@ export function createEnrichment({
signal: lifetime.signal,
});
})
.then((data) => {
if (!lifetime.signal.aborted && data && data.found) job.onData(data);
})
.then(
(answer) => {
const data = job.read ? job.read(answer) : answer;
if (!lifetime.signal.aborted && data && data.found)
job.onData(data);
},
(error) => {
// The source failed: release the key after a cool-down. A layer
// teardown is not a failure and schedules nothing, and a
// permanent refusal is as final as an answer.
if (lifetime.signal.aborted || _enrichFailureIsFinal(error)) return;
flightState._enrichSeen.delete(job.key);
flightState._enrichRetryAfter.set(
job.key,
Date.now() + ENRICH_RETRY_COOLDOWN_MS,
);
},
)
.catch(() => {
/* enrichment never surfaces errors */
})
@@ -119,6 +187,14 @@ export function createEnrichment({
(data) => {
const meta = flightState.records.data.get(icao24);
if (!meta) return;
// A late answer for a callsign the plane no longer flies is dropped.
if (
String(meta.callsign || '')
.trim()
.toUpperCase() !== cs
)
return;
meta.routeCallsign = cs;
meta.airline = data.airline || meta.airline;
if (data.origin && data.destination)
meta.route = { origin: data.origin, destination: data.destination };
@@ -129,6 +205,31 @@ export function createEnrichment({
); // route lookups only fire for the TRACKED plane — front of the queue
}
/** The tracked plane's photo from Planespotters, asked once per hex at the
* front of the queue like its route. A miss is final; a temporary failure
* is asked again after the cool-down. A military aircraft or a converted
* TR-3B is never looked up. The answer is read with
* `parsePlanespottersPhoto` whatever shape the source returns (the upstream
* answer, a server's cached shape, or an already-read photo), so only a
* checked photo is ever stored. */
function _requestPhotoEnrichment(icao24) {
if (!/^[0-9a-f]{6}$/i.test(icao24)) return;
const id = icao24.toLowerCase();
if (isMilitaryIcao(id) || isTr3b(id)) return;
_enqueueEnrich(
`p:${id}`,
{ kind: 'photo', id },
(data) => {
const meta = flightState.records.data.get(id);
if (!meta) return; // evicted while the lookup was in flight
const { src, width, height, photographer, link } = data;
meta.photo = { src, width, height, photographer, link };
},
true,
parsePlanespottersPhoto,
);
}
/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
@@ -196,7 +297,9 @@ export function createEnrichment({
);
const cand = [];
for (const [icao24, bb] of flightState._billboards) {
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
// Answered, queued, negative this session, or cooling down after a
// failure: never spend a token on it.
if (_enrichHeld(`t:${icao24}`)) continue;
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
@@ -233,6 +336,7 @@ export function createEnrichment({
_drainEnrich,
_requestTypeEnrichment,
_requestRouteEnrichment,
_requestPhotoEnrichment,
_ambientBudgetKnobs,
_refillAmbientBudget,
_sweepAmbientEnrichment,
+1
View File
@@ -312,6 +312,7 @@ export function createLifecycle({
flightState._displayFloorState.clear();
flightState._enrichQueue.length = 0;
flightState._enrichSeen.clear();
flightState._enrichRetryAfter.clear();
if (flightState._enrichDripTimer) {
clearTimeout(flightState._enrichDripTimer);
flightState._enrichDripTimer = null;
+107 -22
View File
@@ -5,11 +5,15 @@ import {
lerpAngleDeg,
speedRamp,
courseBetweenCartesians,
floorCoastedPosition,
staleCoastLimitSeconds,
staleCoastSecondsFrom,
staleHoldsForFix,
arcOffsetEnu,
courseSlewCapDps,
limitCourseStep,
corridorPathLatLon,
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
@@ -37,6 +41,22 @@ import {
DISPLAY_CORRIDOR_FAIR_SHARE,
} from './policy.js';
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
* clamped coordinate inside the cell. */
const HALF_CELL_DEG = 0.00049;
/**
* A coordinate held within half a cell of the cell's centre (the short way
* round for longitude), so a read lands in the given cell.
* @param {number} deg @param {number} centreDeg
* @returns {number}
*/
function clampToCell(deg, centreDeg) {
let d = deg - centreDeg;
d -= 360 * Math.round(d / 360);
return centreDeg + Math.max(-HALF_CELL_DEG, Math.min(HALF_CELL_DEG, d));
}
export function createMotion({
flightState,
services,
@@ -232,30 +252,88 @@ export function createMotion({
);
}
// Newest POSITION is older than renderTime. OpenSky can still be receiving
// fresh contact/kinematic messages for that aircraft; freezing at a hard
// 60 s-after-position boundary produced the visible stop → catch-up → stop
// cadence. Coast through the latest real contact plus a bounded grace
// window, with an absolute cap so a stale cached feed cannot drift forever.
// Newest POSITION is older than renderTime. Coast on the last speed and
// heading for five minutes past it, then stop, dimmed as SIGNAL LOST.
// A contact last heard on or near the ground holds there instead, and a
// coast never goes below the ground.
const { coastSec } = _coastPlan(newest, info, elapsedSec);
_extrapolateFix(
newest,
info,
coastSec,
out,
(info && info.turnRateDps) || 0,
);
return coastSec > 0 ? floorCoastedPosition(out, cachedGroundFloor) : out;
}
/**
* How far a contact coasts past its newest fix at a display time
* `elapsedSec` after it: up to five minutes, or not at all for a contact
* that holds at a report on or near the ground. The hold decision is made once
* per report and kept on it.
* @param {{epochMs?: number, time: Cesium.JulianDate}} newest
* @param {object|undefined} info - The contact's record.
* @param {number} elapsedSec
* @returns {{coastSec: number, holds: boolean}}
*/
function _coastPlan(newest, info, elapsedSec) {
const coastLimitSec = staleCoastLimitSeconds({
// `epochMs` is captured once when the poll is normalized. Avoid allocating
// a Date per aircraft on every 12 Hz fleet tick.
// a Date per aircraft on every fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Permit one minute of contact grace but cap any cached-feed drift at
// five minutes. Source backoff is exposed separately as a STALE cue.
minimumSec: 60,
// A plane that stops reporting keeps flying for five minutes, whatever
// its contact time, then stops dimmed as SIGNAL LOST. A missed update
// shows STALE throughout.
minimumSec: 300,
maximumSec: 300,
});
return _extrapolateFix(
const holds = staleHoldsForFix(newest, {
onGround: info?.onGround,
cachedGroundFloor,
});
return {
coastSec: staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}),
holds,
};
}
/**
* Whether a contact has stopped where its coast ended (or held at its last
* report) with no newer report, and since when it has had none.
* @param {string} icao24
* @returns {{reportEpochMs: number, holds: boolean}|null} Null while a
* report is ahead of the display time or the contact is still coasting.
*/
function _signalLost(icao24) {
const newest = flightState._positionHistory.get(icao24)?.at(-1);
if (!newest) return null;
const newestMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
const elapsedSec =
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
if (!(elapsedSec > 0)) return null;
const { coastSec, holds } = _coastPlan(
newest,
info,
Math.min(elapsedSec, coastLimitSec),
out,
(info && info.turnRateDps) || 0,
flightState.records.data.get(icao24),
elapsedSec,
);
if (!(elapsedSec > coastSec)) return null;
return {
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: newestMs,
holds,
};
}
/**
@@ -270,9 +348,12 @@ export function createMotion({
*/
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0;
const speed = Math.min(
Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0,
COAST_MAX_SPEED_MPS,
);
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.true_track) || 0;
@@ -865,12 +946,15 @@ export function createMotion({
);
// Boundary hysteresis: a position jittering across a cell edge would flip
// floors at fleet-tick rate (see stickyFloorCell).
const cell = stickyFloorCell(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
state?.cell,
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const cell = stickyFloorCell(latDeg, lonDeg, state?.cell);
// A cell holds rendered-surface readings at the points they were taken,
// so the floor is read where the contact is, held inside its sticky cell.
const floor = cachedGroundFloor(
clampToCell(latDeg, cell.lat),
clampToCell(lonDeg, cell.lon),
);
const floor = cachedGroundFloor(cell.lat, cell.lon);
const next = state || {
cell,
in: new Cesium.Cartesian3(),
@@ -1112,6 +1196,7 @@ export function createMotion({
return {
_refloorStaleGroundedContacts,
_deadReckon,
_signalLost,
_extrapolateFix,
_isTrackWarmingUp,
_trackedFocusSizePx,
+352 -2
View File
@@ -4,6 +4,7 @@ import { createCivilFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createEnrichment } from './enrichment.js';
import { createTracking } from './tracking.js';
import { LiveSourceError } from '../../sources/live/contract.js';
function services() {
const names = [
@@ -29,6 +30,14 @@ function services() {
};
}
/** The services enrichment reads: which hexes are military or a TR-3B. */
function enrichServices({ military = [], tr3b = [] } = {}) {
return {
militaryRegistry: { isMilitaryIcao: (id) => military.includes(id) },
aircraftPresentation: { isTr3b: (id) => tr3b.includes(id) },
};
}
test('constructing independent flight layers performs no source request and isolates selection', () => {
let requests = 0;
const source = {
@@ -90,7 +99,11 @@ test('aborted enrichment cannot write into a later lifecycle or decrement its ac
},
};
const flightState = createFlightState({ source, services: services() });
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
const enrichment = createEnrichment({
flightState,
services: enrichServices(),
parts: {},
});
let writes = 0;
enrichment._enqueueEnrich(
'test',
@@ -118,7 +131,11 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
},
},
});
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
const enrichment = createEnrichment({
flightState,
services: enrichServices(),
parts: {},
});
assert.doesNotThrow(() =>
enrichment._enqueueEnrich('test', {}, () => assert.fail()),
);
@@ -146,3 +163,336 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
'0°',
);
});
test('the tracked plane’s photo is asked once per hex and lands on its record', async () => {
const queries = [];
let answer = {
found: true,
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
width: 420,
height: 280,
photographer: 'Jan Seler',
link: 'https://www.planespotters.net/photo/1745410/x?utm_source=api',
};
const flightState = createFlightState({
services: services(),
source: {
async getEnrichment(query) {
queries.push(query);
if (answer instanceof Error) throw answer;
return answer;
},
},
});
const enrichment = createEnrichment({
flightState,
services: enrichServices(),
parts: {},
});
flightState.records.data.set('a0f1bb', { callsign: 'AAL1' });
flightState._trackedIcao = 'a0f1bb';
flightState._trackedEntity = {};
enrichment._requestPhotoEnrichment('A0F1BB');
enrichment._requestPhotoEnrichment('a0f1bb');
await new Promise((done) => setImmediate(done));
assert.deepEqual(queries, [{ kind: 'photo', id: 'a0f1bb' }]);
assert.deepEqual(flightState.records.data.get('a0f1bb').photo, {
src: answer.src,
width: 420,
height: 280,
photographer: 'Jan Seler',
link: answer.link,
});
// A failure is not asked again within its cool-down.
answer = new Error('outage');
flightState.records.data.set('abc123', {});
enrichment._requestPhotoEnrichment('abc123');
await new Promise((done) => setTimeout(done, 300));
enrichment._requestPhotoEnrichment('abc123');
await new Promise((done) => setImmediate(done));
assert.equal(queries.length, 2);
assert.equal(flightState.records.data.get('abc123').photo, undefined);
enrichment._requestPhotoEnrichment('not-hex');
assert.equal(queries.length, 2);
flightState.lifetime.abort();
});
test('a military aircraft or a converted TR-3B is never looked up for a photo', async () => {
const queries = [];
const flightState = createFlightState({
services: services(),
source: {
async getEnrichment(query) {
queries.push(query);
return { found: false };
},
},
});
const enrichment = createEnrichment({
flightState,
services: enrichServices({ military: ['ae1234'], tr3b: ['a0f1bb'] }),
parts: {},
});
for (const id of ['ae1234', 'a0f1bb']) {
flightState.records.data.set(id, {});
enrichment._requestPhotoEnrichment(id);
}
enrichment._requestPhotoEnrichment('AE1234');
await new Promise((done) => setImmediate(done));
assert.deepEqual(queries, []);
assert.equal(flightState._enrichSeen.size, 0);
flightState.lifetime.abort();
});
test('a photo answer in the upstream shape or a server’s cached shape lands checked', async () => {
const SRC = 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg';
const LINK = 'https://www.planespotters.net/photo/1745410/x?utm_source=api';
const answers = {
a0f1bb: {
photos: [
{
thumbnail_large: { src: SRC, size: { width: 420, height: 280 } },
link: LINK,
photographer: 'Jan Seler',
},
],
},
abc123: {
found: true,
thumbnail: { src: SRC, width: 420, height: 280 },
photographer: 'Jan Seler',
link: LINK,
},
def456: {
found: true,
thumbnail: { src: 'https://evil.example/a.jpg', width: 1, height: 1 },
photographer: 'Jan Seler',
link: LINK,
},
fed789: { photos: [] },
};
const flightState = createFlightState({
services: services(),
source: { getEnrichment: async ({ id }) => answers[id] },
});
const enrichment = createEnrichment({
flightState,
services: enrichServices(),
parts: {},
});
const ids = Object.keys(answers);
for (const id of ids) flightState.records.data.set(id, {});
for (const id of ids) {
enrichment._requestPhotoEnrichment(id);
// One dispatch per drip gap.
await new Promise((done) => setTimeout(done, 250));
}
const photo = {
src: SRC,
width: 420,
height: 280,
photographer: 'Jan Seler',
link: LINK,
};
assert.deepEqual(flightState.records.data.get('a0f1bb').photo, photo);
assert.deepEqual(flightState.records.data.get('abc123').photo, photo);
assert.equal(flightState.records.data.get('def456').photo, undefined);
assert.equal(flightState.records.data.get('fed789').photo, undefined);
flightState.lifetime.abort();
});
/** An enrichment queue over `answer(query)` with a hand-driven clock. */
function enrichmentClock(t, answer) {
let now = 1_000_000;
t.mock.method(Date, 'now', () => now);
const queries = [];
const flightState = createFlightState({
services: services(),
source: {
getEnrichment(query) {
queries.push(query.id);
return answer(query);
},
},
});
const enrichment = createEnrichment({
flightState,
services: enrichServices(),
parts: { tracking: { _updateTrackedLabelModel() {} } },
});
const settle = () => new Promise((done) => setImmediate(done));
const advance = (ms) => {
now += ms;
};
return { flightState, enrichment, queries, settle, advance };
}
test('a failed lookup is asked again only after its cool-down', async (t) => {
let fail = true;
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
t,
async () => {
if (fail) throw new Error('503');
return { found: true, typeCode: 'A321' };
},
);
flightState.records.data.set('a0f1bb', {});
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
assert.deepEqual(queries, ['a0f1bb']);
advance(59_000);
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
assert.deepEqual(queries, ['a0f1bb'], 'not within the cool-down');
fail = false;
advance(1_000);
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
assert.deepEqual(queries, ['a0f1bb', 'a0f1bb'], 'asked again after it');
assert.equal(flightState.records.data.get('a0f1bb').typeCode, 'A321');
advance(120_000);
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
assert.equal(queries.length, 2, 'an answer is final');
assert.equal(flightState._enrichActive, 0);
flightState.lifetime.abort();
});
test('a synchronous source failure cools down like a rejected one', async (t) => {
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
t,
() => {
throw new Error('boom');
},
);
enrichment._requestPhotoEnrichment('a0f1bb');
await settle();
advance(60_000);
enrichment._requestPhotoEnrichment('a0f1bb');
await settle();
assert.deepEqual(queries, ['a0f1bb', 'a0f1bb']);
flightState.lifetime.abort();
});
test('a "not found" answer is never asked again', async (t) => {
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
t,
async ({ kind }) => (kind === 'photo' ? { photos: [] } : { found: false }),
);
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
advance(1_000); // past the dispatch drip
enrichment._requestPhotoEnrichment('a0f1bb');
await settle();
assert.equal(queries.length, 2);
advance(3_600_000);
enrichment._requestTypeEnrichment('a0f1bb');
enrichment._requestPhotoEnrichment('a0f1bb');
await settle();
assert.equal(queries.length, 2);
assert.equal(flightState._enrichRetryAfter.size, 0);
flightState.lifetime.abort();
});
test('a lookup cut off by the layer’s teardown schedules no retry', async (t) => {
let release;
const { flightState, enrichment, settle } = enrichmentClock(
t,
(query) =>
new Promise((_resolve, reject) => {
release = () => reject(new DOMException('aborted', 'AbortError'));
}),
);
enrichment._requestTypeEnrichment('a0f1bb');
await settle();
flightState.lifetime.abort();
release();
await settle();
assert.equal(flightState._enrichRetryAfter.size, 0);
assert.equal(flightState._enrichSeen.has('t:a0f1bb'), true);
});
test('a permanent refusal is final; a timeout, rate limit, outage or authorization refusal cools down', async (t) => {
const errors = {
aaaaa1: new LiveSourceError('denied', 'adsbdb HTTP 404', { status: 404 }),
aaaaa2: new LiveSourceError('unsupported', 'Enrichment unavailable'),
aaaaa3: new LiveSourceError('limited', 'adsbdb rate limited', {
status: 429,
}),
aaaaa4: new LiveSourceError('unavailable', 'adsbdb HTTP 503', {
status: 503,
}),
aaaaa5: new LiveSourceError('unavailable', 'adsbdb HTTP 408', {
status: 408,
}),
aaaaa6: new Error('network'),
aaaaa7: new LiveSourceError('denied', 'Unauthorized', { status: 401 }),
aaaaa8: new LiveSourceError('denied', 'Forbidden', { status: 403 }),
};
const { flightState, enrichment, queries, settle, advance } = enrichmentClock(
t,
async ({ id }) => {
throw errors[id];
},
);
const ids = Object.keys(errors);
for (const id of ids) {
enrichment._requestTypeEnrichment(id);
await settle();
advance(1_000); // past the dispatch drip
}
assert.deepEqual(queries, ids);
advance(60_000);
for (const id of ids) {
enrichment._requestTypeEnrichment(id);
await settle();
advance(1_000);
}
assert.deepEqual(
queries.slice(ids.length),
['aaaaa3', 'aaaaa4', 'aaaaa5', 'aaaaa6', 'aaaaa7', 'aaaaa8'],
'a 404 and an unsupported lookup are never asked again',
);
assert.equal(flightState._enrichSeen.has('t:aaaaa1'), true);
assert.equal(flightState._enrichRetryAfter.has('t:aaaaa1'), false);
flightState.lifetime.abort();
});
test('a route answer for a callsign the plane no longer flies is dropped', async (t) => {
const pending = [];
const { flightState, enrichment, settle, advance } = enrichmentClock(
t,
(query) => new Promise((resolve) => pending.push({ query, resolve })),
);
const meta = { callsign: 'UAL9' };
flightState.records.data.set('a0f1bb', meta);
enrichment._requestRouteEnrichment('a0f1bb');
await settle();
meta.callsign = 'UAL10'; // the next poll's callsign
advance(1_000);
enrichment._requestRouteEnrichment('a0f1bb');
await settle();
assert.deepEqual(
pending.map(({ query }) => query.id),
['UAL9', 'UAL10'],
);
const route = (code) => ({
found: true,
airline: `${code} Air`,
origin: { code: `${code}O` },
destination: { code: `${code}D` },
});
pending[0].resolve(route('OLD'));
await settle();
assert.equal(meta.route, undefined, 'the late UAL9 answer is ignored');
assert.equal(meta.airline, undefined);
pending[1].resolve(route('NEW'));
await settle();
assert.deepEqual(meta.route, {
origin: { code: 'NEWO' },
destination: { code: 'NEWD' },
});
assert.equal(meta.routeCallsign, 'UAL10');
flightState.lifetime.abort();
});
+9 -1
View File
@@ -132,7 +132,7 @@ export const TRAIL_COLOR = '#00d4ff';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MAX_POINTS = 2000;
// ---------------------------------------------------------------------------
// Render-behind smoothing (PRD WS-C C2 — approved product decision):
@@ -254,6 +254,11 @@ export const ENRICH_MAX_INFLIGHT = 4;
export const ENRICH_DISPATCH_GAP_MS = 200;
/** A lookup whose source failed (an outage, a timeout, a restarting server) may
* be asked again after this long; a real "not found" answer never is. */
export const ENRICH_RETRY_COOLDOWN_MS = 60_000;
// ---------------------------------------------------------------------------
// Ambient fleet type enrichment (2026-07-02 field data: OpenSky's live
// category field is 0/"no info" for ~94% of planes, so ambient classification
@@ -466,3 +471,6 @@ export const FLOOR_SEED_GRACE_MS = 90_000;
* reloads synchronously. */
export const IR_RELOAD_BATCH = 40;
/** Alpha of a contact that has missed an update or lost its signal: the stale fade, for the fleet and the tracked aircraft alike. */
export const STALE_ALPHA = 0.45;
+13 -3
View File
@@ -24,7 +24,8 @@ export function createQueries({
layer,
resolveAsset,
}) {
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
const { isTr3b, tr3bTypeLabel, tr3bAircraftClass } =
services.aircraftPresentation;
const { formatFlightLevel } = services.labels;
const { isMilitaryIcao } = services.militaryRegistry;
const { applyTrackedCameraFrame } = services.camera;
@@ -208,6 +209,12 @@ export function createQueries({
destination: { ...info.route.destination },
}
: null,
// The airframe's Planespotters photo for the Context panel; a military
// aircraft or a converted TR-3B (it would name the real one) shows none.
photo:
info?.photo && !isTr3b(icao24) && !isMilitaryIcao(icao24)
? { ...info.photo }
: null,
};
}
@@ -875,8 +882,9 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null, photo: {src: string, width: number|null, height: number|null, photographer: string, link: string}|null}|null}
* Tracked aircraft info, or null when nothing is tracked. `photo` is the
* airframe's credited Planespotters photo, or null.
*/
getTrackedInfo() {
if (!flightState._trackedIcao) return null;
@@ -906,6 +914,8 @@ export function createQueries({
_toCleanText(described.registration) ||
described.icao24,
position: Cesium.Cartesian3.clone(described.position),
// The airframe's photo for the Context panel, or null.
photo: described.photo,
};
},
+4
View File
@@ -9,6 +9,10 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
export const MISSING_POLL_LIMIT = 3;
/** The tracked contact is kept this long after its newest fix: five minutes
* coasting, then five stopped as SIGNAL LOST. */
export const TRACKED_RETENTION_MS = 10 * 60_000;
/** Cooldown in milliseconds after a transient source error. */
export const ERROR_BACKOFF_INTERVAL = 20000;
+78 -10
View File
@@ -11,6 +11,7 @@ import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
TRACKED_RETENTION_MS,
} from './recordPolicy.js';
/** Source-independent aircraft metadata and bounded missing-poll retention. */
@@ -23,10 +24,45 @@ export class FlightRecords {
};
this.data = new Map();
this.missingPolls = new Map();
// Contacts whose last poll re-sent a fix no newer than the one before.
this.fixRepeated = new Set();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
this.geoidNCache = new Map();
this.geoidReady = false;
}
/**
* Start reading a snapshot taken at `observedAtMs`. A fix re-sent in a
* newer snapshot marks its contact as not updated; the same snapshot read
* again does not.
* @param {number} observedAtMs
*/
beginSnapshot(observedAtMs) {
this.snapshotAdvanced =
!Number.isFinite(observedAtMs) ||
!Number.isFinite(this.lastSnapshotMs) ||
observedAtMs > this.lastSnapshotMs;
if (Number.isFinite(observedAtMs))
this.lastSnapshotMs = Math.max(
this.lastSnapshotMs ?? -Infinity,
observedAtMs,
);
}
/** Note a received fix: the newest fix time, and whether it advanced. */
_noteFix(id, prevMeta, fixEpochMs) {
const prevFixMs = prevMeta?.newestFixMs;
// A fix counts as new only when it moves on by a second: feeds that
// derive fix time from receipt (readsb `now − seen_pos`, 0.1 s) jitter.
if (!Number.isFinite(prevFixMs) || fixEpochMs >= prevFixMs + 1000) {
this.fixRepeated.delete(id);
return fixEpochMs;
}
if (this.snapshotAdvanced) this.fixRepeated.add(id);
return prevFixMs;
}
receive(
observation,
{ viewerLatDeg, viewerLonDeg, trackedId, floorWarmPoints },
@@ -158,6 +194,12 @@ export class FlightRecords {
// (2) otherwise the SAME default policy `alt` already uses, so the two
// never disagree on the genuine "no data yet" case (a never-reported
// aircraft has no prior render height, so it lands here unchanged).
// Neither altitude this poll nor a held render height: the height below
// is the airborne default, invented. The tracked trail keeps no point there.
const renderAltitudeInvented =
pickedAltM == null &&
!onGround &&
!Number.isFinite(prevMeta?.renderAltitudeM);
let renderAltitudeM;
if (pickedAltM != null) {
renderAltitudeM = pickedAltM;
@@ -198,10 +240,22 @@ export class FlightRecords {
!!prevMeta && (prevMeta.onGround === true) !== onGround;
// Store flight metadata for click-to-track labels
const cat = stickyNumber(category, prevMeta?.category, null);
const nextCallsign = stickyText(callsign, prevMeta?.callsign);
// A route (and its airline) belongs to the callsign it was looked up for;
// a new callsign drops it, so an old flight's route never shows under it.
const routeHeld =
Boolean(prevMeta?.routeCallsign) &&
prevMeta.routeCallsign === nextCallsign.toUpperCase();
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
const meta = {
sourceReference: observation.reference,
observedReceiptMs: Date.now(),
callsign: stickyText(callsign, prevMeta?.callsign),
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
callsign: nextCallsign,
altitude: alt,
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
// untouched aviation `altitude` — never rename/replace it (labels,
@@ -209,6 +263,7 @@ export class FlightRecords {
// still read `altitude`/baro).
geoAltitudeM,
renderAltitudeM,
renderAltitudeInvented,
onGround,
// Round 7: sticky airborne history — the landed fast-cull only
// applies to contacts that actually flew this session.
@@ -256,8 +311,11 @@ export class FlightRecords {
typeCode: prevMeta?.typeCode ?? null,
typeName: prevMeta?.typeName ?? null,
registration: prevMeta?.registration ?? null,
airline: prevMeta?.airline ?? null,
route: prevMeta?.route ?? null,
airline: routeHeld ? (prevMeta.airline ?? null) : null,
route: routeHeld ? (prevMeta.route ?? null) : null,
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
// Planespotters photo of the tracked airframe (Core asks it directly).
photo: prevMeta?.photo ?? null,
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
// coords, pre-dead-reckon) — kept distinct from the continuously
// dead-reckoned billboard position for any consumer that needs the
@@ -267,15 +325,25 @@ export class FlightRecords {
};
this.data.set(icao24, meta);
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, retainIds, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
const misses = (this.missingPolls.get(id) || 0) + 1;
// The tracked contact has missed an update: it is stale from the first
// one, and kept (so the camera keeps its target) until ten minutes after
// its newest fix, whatever the snapshot says. With no fix time known it
// takes the ordinary limits below.
const meta = this.data.get(id);
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
if (tracked && Number.isFinite(fixMs)) {
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
this.missingPolls.set(id, misses);
return 'stale';
}
this.missingPolls.delete(id);
return 'remove';
}
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
@@ -284,7 +352,6 @@ export class FlightRecords {
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
const misses = (this.missingPolls.get(id) || 0) + 1;
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
this.missingPolls.set(id, misses);
@@ -297,6 +364,7 @@ export class FlightRecords {
forget(id) {
this.data.delete(id);
this.missingPolls.delete(id);
this.fixRepeated.delete(id);
this.geoidNCache.delete(id);
}
}
+120
View File
@@ -39,6 +39,7 @@ test('civil records keep aviation units separate from render height and survive
assert.equal(first.meta.renderAltitudeM, 1040);
assert.equal(first.fixEpochMs, 1700000000000);
first.meta.typeCode = 'B738';
first.meta.photo = { src: 'https://t.plnspttrs.net/1/2_280.jpg' };
const second = store.receive(
observation({
callsign: '',
@@ -55,6 +56,7 @@ test('civil records keep aviation units separate from render height and survive
assert.equal(second.meta.velocity, 120);
assert.equal(second.meta.true_track, 90);
assert.equal(second.meta.typeCode, 'B738');
assert.equal(second.meta.photo, first.meta.photo, 'the photo survives polls');
assert.equal(Object.hasOwn(second.meta, 'cullPosition'), false);
assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta);
});
@@ -140,3 +142,121 @@ test('civil emergency status follows the latest poll instead of sticking', () =>
assert.equal(next.meta.squawk, '2000');
assert.equal(next.meta.emergency, null);
});
test('a route belongs to its callsign: a new callsign drops it, a blank one keeps it', () => {
const store = records();
store.geoidReady = true;
const first = store.receive(observation({ callsign: 'UAL9' }), view());
first.meta.route = { origin: { code: 'AUS' }, destination: { code: 'DEN' } };
first.meta.airline = 'United';
first.meta.routeCallsign = 'UAL9';
const same = store.receive(observation({ callsign: 'UAL9 ' }), view());
assert.equal(same.meta.route, first.meta.route);
const blank = store.receive(observation({ callsign: '' }), view());
assert.equal(
blank.meta.route,
first.meta.route,
'a dropped callsign is sticky',
);
assert.equal(blank.meta.airline, 'United');
const next = store.receive(observation({ callsign: 'UAL10' }), view());
assert.equal(next.meta.route, null, 'the old route never shows under UAL10');
assert.equal(next.meta.airline, null);
assert.equal(next.meta.routeCallsign, null);
});
test('a contact with no altitude yet is marked as drawn at an invented height; a held one is not', () => {
const store = records();
const none = store.receive(
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
view(),
);
assert.equal(none.meta.renderAltitudeInvented, true);
const known = store.receive(
observation({ positionTimeMs: 1700000005000 }),
view(),
);
assert.equal(known.meta.renderAltitudeInvented, false);
const held = store.receive(
observation({
baroAltitudeM: null,
ellipsoidAltitudeM: null,
positionTimeMs: 1700000010000,
}),
view(),
);
assert.equal(held.meta.renderAltitudeInvented, false);
const parked = records().receive(
observation({ id: 'abc124', baroAltitudeM: null, onGround: true }),
view(),
);
assert.equal(parked.meta.renderAltitudeInvented, false);
});
test('the tracked contact is stale from its first missed update and kept until ten minutes after its newest fix', () => {
const realNow = Date.now;
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
let clock = fixMs + 5_000;
Date.now = () => clock;
try {
for (const snapshot of [
{ complete: true },
{ complete: true, retainIds: new Set(['abc123']) },
{ complete: false },
]) {
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
const absent = (tracked) =>
store.absence('abc123', { ...snapshot, likelyLanded: false, tracked });
clock = fixMs + 15_000;
assert.equal(absent(true), 'stale');
assert.equal(store.missingPolls.get('abc123'), 1);
// Kept long after the untracked limits, measured from the fix time.
clock = fixMs + 9 * 60_000;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000 - 1;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000;
assert.equal(absent(true), 'remove');
assert.equal(store.missingPolls.has('abc123'), false);
}
// Untracked contacts keep their own limits.
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
clock = fixMs + 15_000;
const untracked = (snapshot) =>
store.absence('abc123', { ...snapshot, likelyLanded: false });
assert.equal(untracked({ complete: false }), 'retain');
assert.deepEqual(
[1, 2, 3].map(() => untracked({ complete: true })),
['stale', 'stale', 'remove'],
);
} finally {
Date.now = realNow;
}
});
test('a repeated fix time marks the contact as not updated; removal is unchanged', () => {
const store = records();
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
store.beginSnapshot(fixMs + 2_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
// The next poll re-sends the same position.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
assert.equal(store.missingPolls.has('abc123'), false);
// The same snapshot read again is not a missed update.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
// A newer fix clears it.
store.beginSnapshot(fixMs + 22_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
store.beginSnapshot(fixMs + 32_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
store.forget('abc123');
assert.equal(store.fixRepeated.has('abc123'), false);
});
+25 -1
View File
@@ -39,6 +39,7 @@ import {
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
STALE_ALPHA,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -680,6 +681,19 @@ export function createRendering({
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
/** Fade the tracked 3D model like the billboard while the plane is stale. */
function _applyTrackedModelFade(model) {
const alpha = parts.tracking._trackedStaleCue?.(flightState._trackedIcao)
?.dimmed
? STALE_ALPHA
: 1;
if (model.color?.alpha === alpha) return;
model.color = (
flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN
).withAlpha(alpha);
}
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
@@ -823,6 +837,7 @@ export function createRendering({
parts.motion._trackedDisplayCourse(),
flightState._trackedModel.modelMatrix,
);
_applyTrackedModelFade(flightState._trackedModel);
if (!flightState._trackedModel.ready) return;
const spec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
@@ -849,6 +864,13 @@ export function createRendering({
}
function _fleetTick() {
// Every frame, shown or not: the tracked trail records where its icon is
// drawn and moves its body on (the head callback only reads the result),
// and the tracked label's stale cue follows the clock.
if (flightState._viewer) {
parts.tracking._trailTick?.();
parts.tracking._staleCueTick?.();
}
if (
!flightState._viewer ||
!flightState._billboardCollection ||
@@ -1081,7 +1103,9 @@ export function createRendering({
flightState._cockpitContactMode && !isCockpitNear
? 1
: _fleetBillboardScale(icao24, info?.klass),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseAlpha: flightState.records.missingPolls.get(icao24)
? STALE_ALPHA
: 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
+30 -7
View File
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
staleHoldsForFix,
synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
@@ -22,7 +23,7 @@ export function createFlightSnapshotRenderer({
queries,
}) {
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
const { warmGroundFloor } = groundFloor;
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
const { sampleMeshFloorCells } = meshFloor;
return function applySnapshot(snapshot, viewer) {
const currentIcaos = new Set();
@@ -39,6 +40,7 @@ export function createFlightSnapshotRenderer({
? Cesium.Math.toDegrees(viewerCarto.longitude)
: null;
const floorWarmPoints = [];
records.beginSnapshot(snapshot.observedAtMs);
// Classification and display policy consume source-independent observations.
refreshMilitaryRegistryIfStale();
@@ -112,6 +114,9 @@ export function createFlightSnapshotRenderer({
position: position.clone(),
velocity: meta.velocity,
track: meta.true_track,
// Drawn at an invented height: a trail seeded from history keeps no
// position for it.
inventedHeight: meta.renderAltitudeInvented === true,
});
if (history.length > POSITION_HISTORY_LIMIT) {
history.shift();
@@ -125,7 +130,10 @@ export function createFlightSnapshotRenderer({
// Round 2 (owner): ground traffic appends too — taxi history stays
// live after touchdown (grounded flights positions are already
// surface-clamped via the surfaceM chain, so the ground leg drapes).
if (isTracked) tracking._appendTrailFix(position.clone());
if (isTracked)
tracking._appendTrailFix(position.clone(), fixEpochMs, {
nullAltitude: meta.renderAltitudeInvented === true,
});
} else {
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
if (!modelOwnsGroundVisual) {
@@ -133,21 +141,28 @@ export function createFlightSnapshotRenderer({
}
// Apply fresh kinematics only from a forward synthetic fix. Mutating
// the historical fix reprojects the entire stale interval and snaps
// the rendered aircraft when course or speed changes late.
// the rendered aircraft when course or speed changes late. The stale
// rules apply: none for a contact that holds at its report, a coast
// capped from the real report, never below the ground. It is a motion
// aid, not a report, so it never joins the trail.
const kinematicsChanged =
newest.velocity !== meta.velocity || newest.track !== meta.true_track;
if (kinematicsChanged) {
const holds = staleHoldsForFix(newest, {
onGround: meta.onGround,
cachedGroundFloor,
});
if (kinematicsChanged && !holds) {
const synthetic = synthesizeForwardKinematicsFix(newest, {
epochMs: Date.now(),
velocity: meta.velocity,
track: meta.true_track,
turnRateDps: meta.turnRateDps,
cachedGroundFloor,
});
if (synthetic) {
history.push(synthetic);
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
meta.turnRateDps = turnRateFromFixHistory(history);
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
}
}
}
@@ -230,6 +245,13 @@ export function createFlightSnapshotRenderer({
// (position updates automatically via dead-reckoning CallbackProperty)
if (isTracked && flightState._trackedEntity) {
tracking._updateTrackedLabelModel(icao24);
// Re-ask the tracked plane's lookups each poll. Answered and queued
// keys return at once; a lookup whose source failed runs again once
// its cool-down has passed, and the route uses the callsign this
// snapshot just recorded.
enrichment._requestTypeEnrichment(icao24, true);
enrichment._requestRouteEnrichment(icao24);
enrichment._requestPhotoEnrichment(icao24);
}
}
@@ -245,6 +267,7 @@ export function createFlightSnapshotRenderer({
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
tracked: icao24 === flightState._trackedIcao,
});
if (absence === 'retain') continue;
if (absence === 'stale') {
@@ -300,8 +323,8 @@ export function createFlightSnapshotRenderer({
// floor cells collected in the loop (fire-and-forget, single-flight;
// read synchronously by NEXT poll's clamp — the military-layer pattern).
warmGroundFloor(floorWarmPoints);
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
// Round 4: sample the RENDERED mesh for those same cells (a few probes
// per cell, budget-capped, viewer-proximate, google-3d regime only). Own
// billboards/models are excluded so a vertical probe can't land on an
// aircraft instead of the pavement.
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
// A poll that repeats a contact's last position with new speed or course
// must not move it past where the stale rules allow, and never adds a
// report to the tracked trail.
const ICAO = '00a002';
const SEC = 1000;
/** Any property is a no-op function unless given. */
function stub(overrides = {}) {
return new Proxy(overrides, {
get: (target, key) => (key in target ? target[key] : () => undefined),
});
}
function harness({ ground = () => null }) {
const appended = [];
const history = [];
const flightState = {
_trackedIcao: ICAO,
_trackedEntity: null,
_positionHistory: new Map([[ICAO, history]]),
_billboards: new Map([[ICAO, { position: new Cesium.Cartesian3() }]]),
_billboardCollection: stub(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: stub(),
_displayFloorState: new Map(),
_models: new Map(),
_viewer: null,
_cockpitContactMode: false,
records: { data: new Map() },
};
let meta = null;
let fixEpochMs = 0;
const records = stub({
receive: () => {
flightState.records.data.set(ICAO, meta);
return { prevMeta: meta, meta, groundFlipped: false, fixEpochMs };
},
absence: () => 'retain',
});
const apply = createFlightSnapshotRenderer({
flightState,
records,
militaryRegistry: stub({ isMilitaryIcao: () => false }),
groundFloor: stub({ cachedGroundFloor: ground }),
meshFloor: stub(),
rendering: stub({
_modelOwnsVisual: () => false,
_fleetBillboardColor: () => Cesium.Color.WHITE,
}),
tracking: stub({
_militaryLayerSuppresses: () => false,
_appendTrailFix: (position, epochMs) =>
appended.push({ position, epochMs }),
}),
motion: stub(),
enrichment: stub(),
queries: stub({ _likelyLanded: () => false }),
});
return {
history,
appended,
poll(nextMeta, epochMs) {
meta = nextMeta;
fixEpochMs = epochMs;
apply({ records: [{ id: ICAO }], complete: true }, null);
},
};
}
const lightAircraft = (velocity, track) => ({
rawLat: 38.1436,
rawLon: -122.5561,
renderAltitudeM: 10,
altitude: 40,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'light',
});
test('a low contact whose position repeats while its speed changes is not moved and adds nothing to the trail', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({ ground: () => -30 });
h.poll(lightAircraft(36.4, 63), t0);
const first = h.history[0].position;
for (const [i, velocity] of [30, 22, 14, 9].entries()) {
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(velocity, 63 + i * 20), t0);
}
assert.equal(h.history.length, 1, 'a synthetic fix moved a holding contact');
assert.equal(h.history[0].position, first);
assert.equal(h.appended.length, 1, 'only the real report joins the trail');
});
test('a cruising contact whose position repeats coasts no further than the stale cap, and the synthetic fix is not a report', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
const cruise = (velocity, track) => ({
rawLat: 36,
rawLon: -115,
renderAltitudeM: 9_000,
altitude: 9_000,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'jet',
});
h.poll(cruise(220, 90), t0);
const report = h.history[0].position;
t.mock.timers.tick(300 * SEC);
h.poll(cruise(200, 100), t0);
assert.equal(h.history.length, 2);
const moved = Cesium.Cartesian3.distance(report, h.history[1].position);
assert.ok(
moved <= 220 * 300 + 1,
`the synthetic fix coasted ${moved.toFixed(0)} m`,
);
assert.equal(h.appended.length, 1, 'the synthetic fix joined the trail');
});
test('a report drawn at an invented height says so on its history fix', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll({ ...lightAircraft(60, 90), renderAltitudeInvented: true }, t0);
h.poll({ ...lightAircraft(60, 90), rawLon: -122.55 }, t0 + 10 * SEC);
assert.equal(h.history[0].inventedHeight, true);
assert.equal(h.history[1].inventedHeight, false);
});
test('with the ground unknown, a low barometric altitude alone does not hold a contact', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll(lightAircraft(36.4, 63), t0);
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(30, 80), t0);
// It coasts under the stale cap instead.
assert.equal(h.history.length, 2);
assert.equal(h.history[0].staleHolds, false);
assert.equal(h.appended.length, 1);
});
+421
View File
@@ -0,0 +1,421 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import {
staleCoastSeconds,
staleHoldsAtLastReport,
} from '../../data/motionModel.js';
import { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
import { RENDER_DELAY_SEC } from './policy.js';
const SEC = 1000;
// Ellipsoidal ground at a sea-level airfield (geoid about -32 m).
const FIELD_GROUND_M = -30;
/** A motion module over one contact's history, with a fixed clock. */
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
const flightState = {
records: { data: new Map([['00a001', info]]) },
_positionHistory: new Map([['00a001', fixes]]),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
_scratchCoastCarto: new Cesium.Cartographic(),
now: () => nowMs,
};
const motion = createMotion({
flightState,
services: {
groundFloor: {
cachedGroundFloor: ground,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) ? Math.max(alt, g + 1.5) : alt,
},
camera: {},
focus: {},
},
parts: {},
});
return { flightState, motion };
}
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
const height = (position) => Cesium.Cartographic.fromCartesian(position).height;
test('a stale contact holds at its last report when it reported on the ground or low', () => {
assert.equal(staleHoldsAtLastReport({ onGround: true }), true);
// A light aircraft on final: -30 m barometric, its render height 1.5 m above the known ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: FIELD_GROUND_M + 1.5,
groundM: FIELD_GROUND_M,
}),
true,
);
// Ground unknown: a low barometric altitude alone does not hold it; it
// coasts under the cap.
assert.equal(
staleHoldsAtLastReport({ onGround: false, heightM: 120 }),
false,
);
// A high airfield: 1,600 m barometric but 60 m above its ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 1_660,
groundM: 1_600,
}),
true,
);
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 10_000,
groundM: 20,
}),
false,
);
});
test('a stale contact coasts up to its source limit at any speed, and a holding one not at all', () => {
const coast = (over) =>
staleCoastSeconds({
elapsedSec: 400,
coastLimitSec: 300,
speedMps: 230,
holds: false,
...over,
});
assert.equal(coast(), 300);
assert.equal(coast({ elapsedSec: 200 }), 200);
assert.equal(coast({ coastLimitSec: 60 }), 60);
assert.equal(coast({ speedMps: 1_000 }), 300);
assert.equal(coast({ holds: true }), 0);
assert.equal(coast({ elapsedSec: -5 }), 0);
});
test('a light aircraft on short final goes stale after a low, slow, descending report, the icon holds at that report, not below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 30 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 60 * SEC, 38.1436, -122.5561, FIELD_GROUND_M + 1.5, 36.4, 63),
];
const info = {
altitude: -30,
renderAltitudeM: FIELD_GROUND_M + 1.5,
rawLat: 38.1436,
rawLon: -122.5561,
velocity: 36.4,
true_track: 63,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 60 * SEC,
};
for (const afterSec of [5, 45, 90, 240]) {
const nowMs = t0 + 60 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: () => FIELD_GROUND_M,
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(
Cesium.Cartesian3.distance(drawn, fixes[2].position) < 0.01,
`${afterSec} s after the report the icon is at the report`,
);
assert.ok(height(drawn) >= FIELD_GROUND_M);
}
});
function cruiser(lastContactAfterSec) {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 40, -100, 10_000, 230, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 230, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 230,
true_track: 90,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + lastContactAfterSec * SEC,
};
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
return { at, d };
}
test('a cruising contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
// Contact kept arriving for four minutes after the last position.
const { at, d } = cruiser(240);
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
assert.ok(near(d(at(90)), 230 * 90));
assert.ok(near(d(at(200)), 230 * 200), 'still moving at 200 s');
assert.ok(near(d(at(300)), 230 * 300));
assert.ok(near(d(at(400)), 230 * 300), 'stopped at 300 s');
assert.ok(Cesium.Cartesian3.distance(at(400), at(600)) < 0.01);
});
test('a cruising contact that stops reporting coasts five minutes, then stops', () => {
const { at, d } = cruiser(0);
assert.ok(Math.abs(d(at(60)) - 230 * 60) < 120);
assert.ok(Math.abs(d(at(300)) - 230 * 300) < 600);
assert.ok(Cesium.Cartesian3.distance(at(301), at(420)) < 0.01);
});
test('a bad reported speed coasts no faster than the 1,500 kt continuity limit', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// 2,000 m/s (about 3,900 kt): a corrupt velocity, not a real aircraft.
const fixes = [
fix(t0, 40, -100, 10_000, 2_000, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 2_000, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 2_000,
true_track: 90,
onGround: false,
wasAirborne: true,
};
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
const limitMps = (TRAIL_CONTINUITY_MAX_SPEED_KT * 1852) / 3600;
const d = Cesium.Cartesian3.distance(drawn, fixes[1].position);
assert.ok(Math.abs(d - limitMps * 300) < 1_500, `${d.toFixed(0)} m`);
});
test('coasting never takes a contact below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -105.5, 3_400, 80, 270),
fix(t0 + 15 * SEC, 39.7, -105.514, 3_400, 80, 270),
];
const info = {
altitude: 3_400,
renderAltitudeM: 3_400,
rawLat: 39.7,
rawLon: -105.514,
velocity: 80,
true_track: 270,
onGround: false,
wasAirborne: true,
};
// 3,000 m under the report, rising to 3,600 m along the coast west.
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 60 * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => (lon < -105.52 ? 3_600 : 3_000),
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(height(drawn) >= 3_600, `height ${height(drawn)}`);
});
// ---------------------------------------------------------------------------
// The trail and head across a stale gap.
function services(trail) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: () => null,
floorAltitudeM: (alt) => alt,
},
};
}
function trailHarness({ fixes, nowMs, head }) {
const calls = [];
const trail = {
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
flightState._trackedIcao = '00a001';
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set('00a001', fixes);
let headPosition = head;
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => headPosition,
_trackedDisplayPosition: () => headPosition,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return {
tracking,
flightState,
drawn: () => (calls.at(-1) ?? []).flat(),
head: () => headEntity().polyline.positions.getValue(),
setClock: (ms) => {
clock = ms;
},
setHead: (position) => {
headPosition = position;
},
};
}
test('a holding contact draws no head, and the trail ends at the last report, never past the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1678, -122.527, 303, 45, 200),
fix(t0 + 30 * SEC, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 60 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 90 * SEC, 38.1436, -122.5561, 0, 36.4, 63),
];
const last = fixes.at(-1).position;
// The display time is long past the last report; the icon holds there.
const h = trailHarness({
fixes,
nowMs: t0 + 90 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC,
head: Cesium.Cartesian3.clone(last),
});
h.tracking._startTrail('00a001');
// Every report, including the last, is in the trail: the head no longer
// cuts from the one before it to the icon.
assert.equal(h.drawn().length, 4);
assert.equal(h.drawn().at(-1), h.flightState._trailFixes.at(-1).position);
assert.deepEqual(h.head(), []);
});
test('while the display is between reports, the trail stops at the report behind the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// Reports every 10 s; the icon is drawn 30 s behind, between the 2nd and 3rd.
const fixes = [0, 1, 2, 3, 4, 5].map((i) =>
fix(t0 + i * 10 * SEC, 40, -100 + i * 0.03, 10_000, 230, 90),
);
const nowMs = t0 + 25 * SEC + RENDER_DELAY_SEC * SEC;
const icon = Cesium.Cartesian3.fromDegrees(-100 + 0.075, 40, 10_000);
const h = trailHarness({ fixes, nowMs, head: icon });
h.tracking._startTrail('00a001');
// Reports at 0, 10 and 20 s are behind the icon (display time 25 s).
assert.equal(h.drawn().length, 3);
const head = h.head();
assert.equal(head.length, 2);
assert.ok(Cesium.Cartesian3.distance(head[0], fixes[2].position) < 0.01);
});
test('the hold-or-coast decision is made once per report: a floor that resolves mid-coast does not snap the icon back', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -104.6, 1_900, 70, 270),
fix(t0 + 15 * SEC, 39.7, -104.6123, 1_900, 70, 270),
];
const info = {
altitude: 1_950,
renderAltitudeM: 1_900,
rawLat: 39.7,
rawLon: -104.6123,
velocity: 70,
true_track: 270,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + 120 * SEC,
};
// No floor known when the contact goes stale: baro says well clear of the
// ground, so it coasts. Mid-coast the floor resolves 100 m under the report.
let ground = () => null;
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => ground(lat, lon),
});
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
const before = d(at(30));
assert.ok(Math.abs(before - 70 * 30) < 50, `coasted ${before.toFixed(0)} m`);
ground = () => 1_800;
const after = d(at(31));
assert.ok(
after > before,
`the icon went from ${before.toFixed(0)} m to ${after.toFixed(0)} m`,
);
});
+29 -2
View File
@@ -195,9 +195,32 @@ export function createFlightState({ source, services }) {
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
/** @type {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean, breakBefore: boolean, drawn?: boolean}[]}
* Chronological tracked-aircraft trail (oldest first): reports, and
* (`drawn`) where the icon was drawn between them; `breakBefore` starts a
* new segment. */
flightState._trailPositions = [];
flightState._trailFixes = [];
/** @type {number} Body fixes drawn: those at or before the display time. */
flightState._trailBodyCount = 0;
/** @type {Cesium.Cartesian3|null} Where the tracked icon was last recorded into the trail. */
flightState._trailRecordPosition = null;
/** @type {number} Display time (epoch ms) of that recording. */
flightState._trailRecordMs = -Infinity;
/** @type {string|undefined} The stale cue last written into the tracked label (see `_staleCueTick`). */
flightState._trackedStaleCueText = undefined;
/** @type {{key: string, cue: {text: string, dimmed: boolean}}|null} This frame's tracked stale cue (see `_trackedStaleCue`). */
flightState._trackedStaleCueCache = null;
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
@@ -342,6 +365,10 @@ export function createFlightState({ source, services }) {
flightState._enrichSeen = new Set();
/** Lookup key -> epoch ms before which a failed lookup is not asked again. */
flightState._enrichRetryAfter = new Map();
flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL;
/** Epoch ms the bucket last accounted a refill window from (0 = unset). */
+14 -1
View File
@@ -190,8 +190,20 @@ export function createTesting({
);
}
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
/** Forget every enrichment lookup and the dispatch clock, so a test that
* moved `Date.now` leaves no future-dated drip or cool-down behind. */
function _resetEnrichmentForTest() {
flightState._enrichQueue.length = 0;
flightState._enrichSeen.clear();
flightState._enrichRetryAfter.clear();
flightState._enrichLastDispatchMs = 0;
if (flightState._enrichDripTimer) {
clearTimeout(flightState._enrichDripTimer);
flightState._enrichDripTimer = null;
}
}
/** Exercise the exact asynchronous fleet loader and return its admitted model. */
async function _ensureFleetModelForTest(icao24) {
await parts.rendering._ensureModel(icao24);
return flightState._models.get(icao24) || null;
@@ -212,5 +224,6 @@ export function createTesting({
_trackedBillboardColorForTest,
_driveFleetModelHandoffForTest,
_ensureFleetModelForTest,
_resetEnrichmentForTest,
};
}
+274 -85
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
@@ -21,12 +20,31 @@ import {
TRACKED_MODEL_MAX_LOAD_FAILS,
TRACKED_MODEL_RETRY_BACKOFF_MS,
TRAIL_MAX_POINTS,
DR_CORRECTION_MS,
RENDER_DELAY_SEC,
TRAIL_COLOR,
CYAN_TRANSPARENT,
STALE_ALPHA,
} from './policy.js';
import {
drawTrailSegments,
historyFixEpochMs,
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
trailBackfillFixes,
trailBodyCount,
trailBodyCountOutsideModel,
trailBodySegments,
trailHeadStartFix,
trailRecordingBlocked,
TRAIL_RECORD_STEP_M,
TRAIL_RECORD_STEP_MS,
} from '../../data/trailContinuity.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const STALE_CYAN = Cesium.Color.CYAN.withAlpha(STALE_ALPHA);
export function createTracking({
flightState,
services,
@@ -333,18 +351,81 @@ export function createTracking({
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
* Put one report into the tracked aircraft's trail, in time order, and
* refresh the rendered trail. Caller passes an owned (cloned) Cartesian3. A
* report that breaks from its neighbour starts a new segment (see
* trailContinuity).
* @param {Cesium.Cartesian3} position - The report's position.
* @param {number} [epochMs] - The report's time; defaults to the tracked
* aircraft's newest position-history fix, which every caller has just pushed.
* @param {{nullAltitude?: boolean}} [options] - `nullAltitude`: the report
* had no altitude, so its position is not kept and the line breaks there.
* A report for a time the display has already passed replaces what was
* drawn after it (see `insertTrailFix`).
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(position);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS)
flightState._trailPositions.shift();
function _appendTrailFix(position, epochMs, { nullAltitude = false } = {}) {
const history = flightState._positionHistory.get(flightState._trackedIcao);
const timeMs = Number.isFinite(epochMs)
? epochMs
: historyFixEpochMs(history?.[history.length - 1]);
insertTrailFix(
flightState._trailFixes,
position,
timeMs,
TRAIL_MAX_POINTS,
{
nullAltitude,
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
settleMs: DR_CORRECTION_MS,
},
);
_refreshTrailDisplay();
}
/**
* Per-frame trail work, run from the layer's preRender pass whether or not
* the head is visible: record where the tracked icon is drawn (at most once
* a second or every 150 m) and move the body on as the display time passes
* reports. The head callback only reads the result.
*/
function _trailTick() {
if (!flightState._trackedIcao || !flightState._trail) return;
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
let changed = false;
if (
flightState._trailFixes.length &&
!parts.motion._isTrackWarmingUp() &&
!trailRecordingBlocked(flightState._trailFixes, displayMs)
) {
const drawn = parts.motion._trackedDisplayPosition(
flightState._trackedIcao,
);
const last = flightState._trailRecordPosition;
if (
drawn &&
(!last ||
displayMs - flightState._trailRecordMs >= TRAIL_RECORD_STEP_MS ||
Cesium.Cartesian3.distance(last, drawn) >= TRAIL_RECORD_STEP_M)
) {
flightState._trailRecordMs = displayMs;
flightState._trailRecordPosition = Cesium.Cartesian3.clone(
drawn,
flightState._trailRecordPosition ?? undefined,
);
changed = recordTrailPoint(
flightState._trailFixes,
Cesium.Cartesian3.clone(drawn),
displayMs,
TRAIL_MAX_POINTS,
);
}
}
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
_refreshTrailDisplay();
}
/**
* Renders the trail with its head clamped to the render-behind display
* position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the
@@ -354,17 +435,33 @@ export function createTracking({
*/
function _refreshTrailDisplay() {
// The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest
// fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
// now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane.
// The cheap per-frame _trailHeadEntity segment bridges the last body point to the
// delayed dead-reckoned head, so the body primitive only rebuilds on a real fix
// (poll cadence), never at motion cadence.
// The trail BODY is the accumulated fixes at or before the delayed display time
// (now − RENDER_DELAY_SEC); newer fixes are AHEAD of the icon, so drawing them
// would push the trail in front of the plane. The cheap per-frame
// _trailHeadEntity segment bridges the last body point to the delayed
// dead-reckoned head, so the body rebuilds only on a real fix or when the
// display time passes one, never at motion cadence.
// A break lifts the pen: each segment is its own line, nothing between them.
if (!flightState._trail) return;
flightState._trail.setPositions(
flightState._trailPositions.length > 1
? flightState._trailPositions.slice(0, -1)
: flightState._trailPositions,
flightState._trailBodyCount = _trailBodyCountNow();
drawTrailSegments(
flightState._trail,
trailBodySegments(flightState._trailFixes, flightState._trailBodyCount),
);
}
/** Fixes at or before the time the aircraft is drawn (now − RENDER_DELAY_SEC),
* less the newest recorded points inside its 3D model. */
function _trailBodyCountNow() {
return trailBodyCountOutsideModel(
flightState._trailFixes,
trailBodyCount(
flightState._trailFixes,
flightState.now() - RENDER_DELAY_SEC * 1000,
),
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
);
}
@@ -377,21 +474,24 @@ export function createTracking({
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
// Seed every report. The body only draws those at or before the delayed
// display time (now − RENDER_DELAY_SEC); newer ones join it as the display
// passes them. A synthetic forward fix (it carries `reportEpochMs`) is a
// motion aid, never a report. A report drawn at an invented height keeps
// no position and breaks the line.
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position));
}
if (Number.isFinite(fix.reportEpochMs)) continue;
insertTrailFix(
flightState._trailFixes,
Cesium.Cartesian3.clone(fix.position),
historyFixEpochMs(fix),
TRAIL_MAX_POINTS,
{ nullAltitude: fix.inventedHeight === true },
);
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, {
@@ -412,14 +512,13 @@ export function createTracking({
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — which is ~now, AHEAD of the delayed icon —
// so the segment would draw IN FRONT of the plane. Likewise during warm-up the
// icon predates all real history, so there is no valid body point behind it.
// The head starts at the last DISPLAYED body point, the newest fix at or
// before the delayed display time; a fix ahead of the icon would draw the
// segment IN FRONT of the plane. During warm-up the icon predates all real
// history, so there is no valid body point behind it.
if (
!flightState._trackedIcao ||
flightState._trailPositions.length < 2 ||
!flightState._trailFixes.length ||
parts.motion._isTrackWarmingUp()
)
return [];
@@ -427,12 +526,19 @@ export function createTracking({
parts.motion._trackedTrailCached() ||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start =
flightState._trailPositions[
flightState._trailPositions.length - 2
];
// The last body point (a report, or where the icon was drawn a
// moment ago) → the icon. The body is moved on by _trailTick, never
// here: a callback must not add or remove entities.
// No head across a break: a body point more than 150 km away, or
// too long ago or too far to be one path, stays detached; none while
// the aircraft holds at its last point.
const last = trailHeadStartFix(
flightState._trailFixes,
head,
flightState.now(),
flightState._trailBodyCount,
);
if (!last) return [];
// On a contact that has not moved this segment runs from inside the
// model out to its own anchor — a line through the fuselage. The END
// never gives, so a moving trail still terminates on the tail; the
@@ -441,7 +547,7 @@ export function createTracking({
// See trailHeadStart. Read `head` in place and clone only on the draw
// path — a suppressed parked contact runs this every frame.
const from = trailHeadStart(
start,
last.position,
head,
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
@@ -520,7 +626,13 @@ export function createTracking({
const time = observedAtMs / 1000;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAlt });
parsed.push({
lat,
lon,
baroAlt,
onGround: waypoint.onGround === true,
timeMs: observedAtMs,
});
}
if (!parsed.length) return;
@@ -543,27 +655,30 @@ export function createTracking({
)
return;
const older = [];
let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet
for (const { lat, lon, baroAlt } of parsed) {
const baroM = Number.isFinite(baroAlt)
? baroAlt + geoidHeight(lat, lon)
: null;
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// No baro + unresolved floor: hold the previous waypoint's altitude
// (continuity — never a dive/spike to a made-up height). Leading points
// with nothing to carry keep the old 10 km airborne default.
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(
flightState._trailPositions.length - TRAIL_MAX_POINTS,
);
}
// Every waypoint is kept, each leg its own segment. A ground waypoint is
// at altitude 0, floored at the ground. A waypoint with no altitude is a
// break on both sides: it sits on the ground where the floor is known and
// is skipped otherwise; a skipped point still breaks the line.
const backfill = trailBackfillFixes(
parsed.map(({ lat, lon, baroAlt, onGround, timeMs }) => {
const known = Number.isFinite(baroAlt) ? baroAlt : onGround ? 0 : null;
const altM = floorAltitudeM(
known == null ? null : known + geoidHeight(lat, lon),
cachedGroundFloor(lat, lon),
);
return {
position:
altM == null ? null : Cesium.Cartesian3.fromDegrees(lon, lat, altM),
timeMs,
nullAltitude: known == null,
};
}),
);
flightState._trailFixes = joinTrailBackfill(
backfill,
flightState._trailFixes,
TRAIL_MAX_POINTS,
);
_refreshTrailDisplay();
}
@@ -573,7 +688,9 @@ export function createTracking({
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
if (flightState._trail) flightState._trail.clear();
if (
flightState._trailHeadEntity &&
@@ -737,14 +854,90 @@ export function createTracking({
flightState._pendingTrackingRestore = null;
}
/**
* The tracked plane's stale cue and whether it is drawn with the stale fade.
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer
* report has come.
* - STALE, dimmed: it has missed an update and still coasts.
* - STALE, not dimmed: the whole feed is backing off.
* @param {string} icao24
* @returns {{text: string, dimmed: boolean}}
*/
function _trackedStaleCue(icao24) {
const records = flightState.records;
const misses = records.missingPolls.get(icao24) || 0;
const repeated = records.fixRepeated?.has(icao24) === true;
const backoff = flightState.feed._backoff === true;
// Computed once per frame for the label, the icon colour and the model
// fade; a poll between frames changes the key.
const frame = flightState._viewer?.scene?.frameState?.frameNumber;
const key = Number.isFinite(frame)
? `${frame}|${icao24}|${misses}|${repeated}|${backoff}`
: null;
const cached = flightState._trackedStaleCueCache;
if (key !== null && cached?.key === key) return cached.cue;
const cue = _computeTrackedStaleCue(
icao24,
misses > 0 || repeated,
backoff,
);
flightState._trackedStaleCueCache = key === null ? null : { key, cue };
return cue;
}
/** `_trackedStaleCue` without the per-frame cache. A repeated fix counts as missed. */
function _computeTrackedStaleCue(icao24, missed, backoff) {
const lost = parts.motion._signalLost(icao24);
if (lost && (missed || !lost.holds)) {
const sec = Math.max(
0,
Math.floor((flightState.now() - lost.reportEpochMs) / 1000),
);
const time =
sec < 60
? `${formatNumber(sec, { useGrouping: false })} ${unitLabel('s')}`
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
}
if (missed) return { text: t('STALE'), dimmed: true };
if (backoff) return { text: t('STALE'), dimmed: false };
return { text: '', dimmed: false };
}
/** The tracked billboard's colour: cyan, faded while stale, clear while the 3D model is the visual. */
function _trackedIconColor() {
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
return CYAN_TRANSPARENT;
return _trackedStaleCue(flightState._trackedIcao).dimmed
? STALE_CYAN
: Cesium.Color.CYAN;
}
/**
* Re-word the tracked label when its stale cue changes (STALE, SIGNAL LOST,
* each new second or minute of it). Run from the layer's per-frame pass.
*/
function _staleCueTick() {
const icao24 = flightState._trackedIcao;
if (!icao24 || !flightState._trackedEntity) return;
const text = _trackedStaleCue(icao24).text;
if (text === flightState._trackedStaleCueText) return;
flightState._trackedStaleCueText = text;
_updateTrackedLabelModel(icao24);
}
/** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" +
* "ORIG → DEST". The route line is gated by
* routePlausible so a wrong-leg adsbdb route is hidden, not displayed.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata), the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* velocity/altitude as live. */
* The first line carries the stale cue (`_trackedStaleCue`): STALE while
* the plane coasts on dead reckoning after a missed update, SIGNAL LOST
* once the coast has ended, so last-known velocity/altitude are never
* presented as live. */
function _trackedLabelText(icao24) {
const info = flightState.records.data.get(icao24);
@@ -765,10 +958,7 @@ export function createTracking({
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('STALE')
: '';
const stale = _trackedStaleCue(icao24).text;
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
// operator/type identity is exactly what the Easter egg is replacing.
@@ -967,15 +1157,9 @@ export function createTracking({
CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1,
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
() =>
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
? CYAN_TRANSPARENT
: Cesium.Color.CYAN,
false,
),
// Solid cyan (faded while stale) when the billboard is the visual (zoomed out, 3D off,
// or model still loading); transparent once the STANDALONE tracked model is actually up.
color: new Cesium.CallbackProperty(_trackedIconColor, false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
@@ -1060,6 +1244,7 @@ export function createTracking({
// starts one.
parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue
parts.enrichment._requestRouteEnrichment(icao24);
parts.enrichment._requestPhotoEnrichment(icao24);
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
// aircraft's history is retrievable on select. Grounded flights positions
// are already surface-clamped (the surfaceM chain), so seeds/appends drape.
@@ -1256,6 +1441,7 @@ export function createTracking({
_trackedModelRegimeActive,
_syncTracked2dRotation,
_appendTrailFix,
_trailTick,
_refreshTrailDisplay,
_startTrail,
_backfillTrail,
@@ -1266,6 +1452,9 @@ export function createTracking({
_applyPendingTrackingRestore,
_cancelPendingTrackingRestore,
_trackedLabelText,
_trackedStaleCue,
_trackedIconColor,
_staleCueTick,
_updateTrackedLabelModel,
_syncTrackedBillboardImage,
_refreshTr3bContact,
+414
View File
@@ -0,0 +1,414 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_MAX_POINTS } from './policy.js';
import { trailHeadJoins, trailPairBreaks } from '../../data/trailContinuity.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const NM = 1852;
function services({ trails, floor = () => null } = {}) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: floor,
floorAltitudeM: (alt, ground) =>
Number.isFinite(alt)
? alt
: Number.isFinite(ground)
? ground + 1
: null,
},
};
}
/** A fake trail that records every segment list it is given. */
function recordingTrail() {
const calls = [];
return {
calls,
// What draws: a lone point is a segment with no line.
last: () =>
(calls[calls.length - 1] ?? []).filter((segment) => segment.length >= 2),
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {
calls.push([]);
},
destroy() {},
}),
};
}
function viewer() {
const added = [];
return {
added,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
}
/**
* A tracked aircraft whose live history holds `liveFixes` ({timeMs, lat, lon}),
* with a track endpoint answering `waypoints`.
*/
function trackedHarness({
liveFixes = [],
waypoints = null,
nowMs,
floor,
head = null,
} = {}) {
const trail = recordingTrail();
const svc = services({ trails: trail, floor });
const flightState = createFlightState({
services: svc,
source: {
label: 'Fixture aircraft',
async getTrack() {
if (!waypoints) throw new Error('404');
return { records: waypoints };
},
},
});
const v = viewer();
flightState._viewer = v;
flightState._trackedIcao = 'abc123';
flightState.now = () => nowMs;
flightState._positionHistory.set(
'abc123',
liveFixes.map((fix) => ({
time: Cesium.JulianDate.fromDate(new Date(fix.timeMs)),
epochMs: fix.timeMs,
position: Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 10_000),
})),
);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => head,
_trackedDisplayPosition: () => head,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
return { trail, tracking, flightState, viewer: v };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function waypoint(timeMs, lat, lon, baroAltitudeM = 10_000) {
return { observedAtMs: timeMs, latitude: lat, longitude: lon, baroAltitudeM };
}
/** `count` waypoints one minute apart flying east at ~420 kt from (lat, lon). */
function leg(startMs, lat, lon, count) {
return Array.from({ length: count }, (_, i) =>
waypoint(startMs + i * MINUTE, lat, lon + i * 0.12),
);
}
function headPositions(v) {
const entity = v.added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return entity.polyline.positions.getValue();
}
test('a pair 40 minutes and 300 km apart stays joined', () => {
assert.equal(trailPairBreaks(40 * MINUTE, 300_000), false);
});
test('a pair 1 minute and 200 km apart breaks: faster than 1,500 kt', () => {
assert.equal(trailPairBreaks(MINUTE, 200_000), true);
});
test('a gap longer than 48 minutes breaks, and time that does not advance breaks', () => {
assert.equal(trailPairBreaks(49 * MINUTE, 10_000), true);
assert.equal(trailPairBreaks(48 * MINUTE, 10_000), false);
assert.equal(trailPairBreaks(0, 0), true);
assert.equal(trailPairBreaks(-MINUTE, 100), true);
});
test('a parked aircraft two hours apart within 0.1 nm stays joined', () => {
assert.equal(trailPairBreaks(2 * HOUR, 0.09 * NM), false);
assert.equal(trailPairBreaks(3 * HOUR + MINUTE, 0.09 * NM), true);
assert.equal(trailPairBreaks(2 * HOUR, 0.2 * NM), true);
});
test('the head joins within 150 km under the time and speed test', () => {
assert.equal(trailHeadJoins(30_000, 5_000), true);
assert.equal(trailHeadJoins(30 * MINUTE, 151_000), false);
assert.equal(trailHeadJoins(HOUR, 5_000), false);
});
test('a backfill with gaps of 10 h, 2 h, 1.2 h and 6.5 h draws five segments and no line across a gap', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const legs = [];
let start = t0;
const gaps = [10 * HOUR, 2 * HOUR, 1.2 * HOUR, 6.5 * HOUR];
const anchors = [
[30, 31],
[12, 0],
[14, -15],
[20, -40],
[35, -75],
];
for (let i = 0; i < anchors.length; i++) {
const points = leg(start, anchors[i][0], anchors[i][1], 6);
legs.push(points);
start = points[points.length - 1].observedAtMs + (gaps[i] ?? 0);
}
const waypoints = legs.flat();
const lastLeg = legs[legs.length - 1];
const liveStart = lastLeg[lastLeg.length - 1].observedAtMs + MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 35,
lon: -75 + 0.72 + i * 0.12,
}));
const nowMs = liveStart + 3 * MINUTE;
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
waypoints,
nowMs,
});
tracking._startTrail('abc123');
await settle();
assert.equal(flightState._trailFixes.length, waypoints.length + 3);
const segments = trail.last();
assert.equal(segments.length, 5);
const bodyCount = segments.reduce((sum, segment) => sum + segment.length, 0);
// The body draws every fix at or before the display time: all of them here.
assert.equal(bodyCount, waypoints.length + 3);
for (const [index, segment] of segments.entries()) {
const expected = legs[index].map((point) =>
Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, 10_000),
);
for (const position of segment.slice(0, expected.length)) {
assert.ok(
expected.some((e) => Cesium.Cartesian3.distance(e, position) < 1),
`segment ${index} holds a point from another leg`,
);
}
}
});
test('a null-altitude waypoint breaks the line, and nothing is drawn at 10 km', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const waypoints = leg(t0, 40, 10, 6);
waypoints[3] = waypoint(waypoints[3].observedAtMs, 40, 10.36, null);
const nowMs = t0 + 8 * MINUTE;
const liveFixes = [
{ timeMs: t0 + 6 * MINUTE, lat: 40, lon: 10.72 },
{ timeMs: t0 + 7 * MINUTE, lat: 40, lon: 10.84 },
];
// Unknown floor: the null point is skipped, and the line still breaks there.
const skipped = trackedHarness({ liveFixes, waypoints, nowMs });
skipped.tracking._startTrail('abc123');
await settle();
assert.equal(
skipped.flightState._trailFixes.length,
waypoints.length - 1 + 2,
);
const segments = skipped.trail.last();
assert.deepEqual(
segments.map((segment) => segment.length),
[3, 4],
);
for (const segment of segments) {
for (const position of segment) {
const height = Cesium.Cartographic.fromCartesian(position).height;
assert.ok(Math.abs(height - 10_000) < 1, 'only real altitudes drawn');
}
}
// Known floor: the point is kept on the ground, alone, joined to nothing.
const grounded = trackedHarness({
liveFixes,
waypoints,
nowMs,
floor: () => 120,
});
grounded.tracking._startTrail('abc123');
await settle();
assert.equal(grounded.flightState._trailFixes.length, waypoints.length + 2);
assert.deepEqual(
grounded.trail.last().map((segment) => segment.length),
[3, 4],
);
const kept = grounded.flightState._trailFixes[3];
assert.ok(
Math.abs(Cesium.Cartographic.fromCartesian(kept.position).height - 121) < 1,
);
});
test('a history tail far from the first live fix does not connect to it', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
// History ends 20 minutes before the first live fix, 1,400 km away.
const waypoints = leg(t0, 50, 0, 5);
const liveStart = t0 + 24 * MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 50,
lon: 20 + i * 0.12,
}));
const { trail, tracking } = trackedHarness({
liveFixes,
waypoints,
nowMs: liveStart + 3 * MINUTE,
});
tracking._startTrail('abc123');
await settle();
assert.deepEqual(
trail.last().map((segment) => segment.length),
[5, 3],
);
});
test('the head is drawn to a nearby aircraft and not to one more than 150 km away', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const near = Cesium.Cartesian3.fromDegrees(1.3, 50, 10_000);
const joined = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: near,
});
joined.tracking._startTrail('abc123');
const drawn = headPositions(joined.viewer);
assert.equal(drawn.length, 2);
assert.ok(Cesium.Cartesian3.distance(drawn[1], near) < 1e-6);
const far = Cesium.Cartesian3.fromDegrees(4, 50, 10_000);
const detached = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: far,
});
detached.tracking._startTrail('abc123');
assert.deepEqual(headPositions(detached.viewer), []);
// An hour without a fix: the head no longer joins the last body point.
const stale = trackedHarness({ liveFixes, nowMs: t0 + HOUR, head: near });
stale.tracking._startTrail('abc123');
assert.deepEqual(headPositions(stale.viewer), []);
});
test('a live fix after a one-hour feed gap starts a new segment', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
});
tracking._startTrail('abc123');
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3],
);
const later = t0 + 2 * MINUTE + HOUR;
// The display time moves past the new fixes.
flightState.now = () => later + 3 * MINUTE;
for (const [i, lon] of [3, 3.12, 3.24].entries()) {
tracking._appendTrailFix(
Cesium.Cartesian3.fromDegrees(lon, 50, 10_000),
later + i * MINUTE,
);
}
assert.equal(flightState._trailFixes.length, 6);
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3, 3],
);
});
test('every waypoint of a 24 h history survives, and the trail stays within TRAIL_MAX_POINTS', async () => {
// A full day at one fix per minute.
const day = 24 * 60;
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const full = leg(t0, 10, -100, day);
const liveStart = t0 + day * MINUTE;
const liveFixes = [{ timeMs: liveStart, lat: 10, lon: -100 + day * 0.12 }];
const kept = trackedHarness({
liveFixes,
waypoints: full,
nowMs: liveStart + MINUTE,
});
kept.tracking._startTrail('abc123');
await settle();
assert.ok(TRAIL_MAX_POINTS >= day + 500);
assert.equal(kept.flightState._trailFixes.length, full.length + 1);
assert.deepEqual(
kept.trail.last().map((segment) => segment.length),
[day + 1],
);
const count = TRAIL_MAX_POINTS + 120;
const large = leg(
liveStart - count * MINUTE,
10,
-100 + (day - count) * 0.12,
count,
);
const capped = trackedHarness({
liveFixes,
waypoints: large,
nowMs: liveStart + MINUTE,
});
capped.tracking._startTrail('abc123');
await settle();
const fixes = capped.flightState._trailFixes;
assert.equal(fixes.length, TRAIL_MAX_POINTS);
// The oldest go first: the newest live fix is still the last point.
assert.equal(fixes[fixes.length - 1].timeMs, liveStart);
});
+846
View File
@@ -0,0 +1,846 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { RENDER_DELAY_SEC, STALE_ALPHA } from './policy.js';
// Between reports, a tracked trail is the path the icon was actually drawn
// along: the real display position, recorded as the frames go by. These tests
// drive the real motion and display path frame by frame.
const SEC = 1000;
const ICAO = '00a001';
const T0 = Date.UTC(2026, 10, 9, 21, 0);
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
function services(trail, ground) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
aircraftPresentation: {
isTr3b: () => false,
tr3bTypeLabel: (id, label) => label,
},
focus: {
nearFarScalarValueAtDistance: () => 1,
clearFocusTarget() {},
publishFocusTargetFromCachedPosition() {},
},
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: ground,
stickyFloorCell: (lat, lon) => ({ lat, lon }),
neighborFloorM: () => null,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) && Number.isFinite(alt)
? Math.max(alt, g + 1.5)
: alt,
},
};
}
/**
* Real motion, real display position and real tracking over one contact.
* `frame()` runs one rendered frame: the layer's preRender work, then the
* head callback, as Cesium does.
*/
function harness({
fixes,
info,
nowMs,
ground = () => null,
envelopeM = null,
}) {
const segmentsCalls = [];
let inHead = false;
const trail = {
createTrail: () => ({
setSegments(segments) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail, ground);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
flightState.records.data.set(ICAO, info);
let frameNumber = 0;
const added = [];
let entityChanges = 0;
flightState._viewer = {
isDestroyed: () => false,
scene: {
frameState: {
get frameNumber() {
return frameNumber;
},
},
},
entities: {
add(definition) {
entityChanges += 1;
added.push(definition);
return definition;
},
remove() {
entityChanges += 1;
},
},
};
flightState._trackedIcao = ICAO;
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set(ICAO, fixes);
const parts = {
rendering: { _modelOwnsVisual: () => false, _modelSpec: () => ({}) },
queries: { _contactLabel: (id, info) => info?.callsign || id },
};
parts.motion = createMotion({ flightState, services: svc, parts });
if (envelopeM != null) {
// A 3D model is the visual: its centre is the drawn position.
parts.motion._trackedModelCenterWorld = () =>
parts.motion._trackedDisplayCached();
parts.motion._trackedModelEnvelopeM = () => envelopeM;
}
const tracking = createTracking({
flightState,
services: svc,
parts,
layer: {},
});
parts.tracking = tracking;
const realDateNow = Date.now;
Date.now = () => clock;
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
const head = () => {
const before = entityChanges;
inHead = true;
try {
return headEntity().polyline.positions.getValue();
} finally {
inHead = false;
assert.equal(
entityChanges,
before,
'the head callback added or removed an entity',
);
}
};
/** Drawn icon positions, one per frame. */
const drawn = [];
const frame = ({ readHead = true } = {}) => {
frameNumber += 1;
tracking._trailTick();
const icon = parts.motion._trackedDisplayPosition(ICAO);
if (icon) drawn.push(Cesium.Cartesian3.clone(icon));
if (readHead) head();
};
const run = (toMs, { frameMs = 100, readHead = true } = {}) => {
while (clock < toMs) {
clock = Math.min(toMs, clock + frameMs);
frame({ readHead });
}
};
return {
flightState,
tracking,
motion: parts.motion,
head,
drawn,
run,
body: () => segmentsCalls.at(-1) ?? [],
bodyPoints: () => (segmentsCalls.at(-1) ?? []).flat(),
restore: () => {
Date.now = realDateNow;
},
};
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
if (path.length === 1) return Cesium.Cartesian3.distance(p, path[0]);
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
/** No vertex turns the line back sharper than 120° over a short step. */
function assertNoSpur(line, label) {
const u = new Cesium.Cartesian3();
const v = new Cesium.Cartesian3();
for (let i = 1; i < line.length - 1; i++) {
Cesium.Cartesian3.subtract(line[i], line[i - 1], u);
Cesium.Cartesian3.subtract(line[i + 1], line[i], v);
const lu = Cesium.Cartesian3.magnitude(u);
const lv = Cesium.Cartesian3.magnitude(v);
if (!(lu > 0 && lv > 0) || Math.min(lu, lv) > 500) continue;
const cos = Cesium.Cartesian3.dot(u, v) / (lu * lv);
assert.ok(
cos >= -0.5,
`${label}: spur at vertex ${i} of ${line.length} (turn ${(
(Math.acos(cos) * 180) /
Math.PI
).toFixed(0)}°)`,
);
}
}
const indexOfPoint = (line, p) =>
line.findIndex((q) => Cesium.Cartesian3.distance(p, q) < 0.01);
// A departing jet: four northbound climb reports, then a left turn through
// west to south with no report while the icon flies it.
function departure() {
const fixes = [
fix(T0, 37.6329, -122.3661, 213, 85, 8),
fix(T0 + 30 * SEC, 37.6542, -122.3616, 495, 95, 0),
fix(T0 + 60 * SEC, 37.6813, -122.3615, 808, 104, 350),
fix(T0 + 90 * SEC, 37.7066, -122.3755, 1257, 112, 340),
];
const info = {
altitude: 1257,
renderAltitudeM: 1257,
rawLat: 37.7066,
rawLon: -122.3755,
velocity: 112,
true_track: 340,
turnRateDps: -2,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 200 * SEC,
};
return { fixes, info };
}
test('a report newer than the display time arrives mid-turn: the trail between the two reports stays on the drawn arc', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// 40 s of turn with no new report: the icon flies the arc.
h.run(start + 40 * SEC);
const arc = h.drawn.slice();
// The next report is where the plane really was 60 s after the last one,
// still ahead of the display time (normal 30 s delay, slow feed). The icon
// now interpolates the straight line between the two reports and is eased
// onto it.
const at60 = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 150 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(at60);
const report = fix(
T0 + 150 * SEC,
Cesium.Math.toDegrees(c.latitude),
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
220,
);
fixes.push(report);
info.true_track = 220;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// The display passes the new report.
h.run(start + 75 * SEC);
const line = h.bodyPoints();
const from = indexOfPoint(line, fixes[3].position);
const to = indexOfPoint(line, report.position);
assert.ok(from >= 0 && to > from, 'both reports are on the trail');
const between = line.slice(from, to + 1);
// Every vertex between the reports is where the icon was drawn...
const drawnPath = [fixes[3].position, ...h.drawn, report.position];
for (const vertex of between) {
const off = distanceToPath(vertex, drawnPath);
assert.ok(
off < 50,
`trail vertex ${off.toFixed(0)} m off the drawn path`,
);
}
// ...and the arc the icon flew is still there: no straight chord. (The
// arc strays far from the chord, so a chord would fail this.)
const chord = [fixes[3].position, report.position];
assert.ok(Math.max(...arc.map((p) => distanceToPath(p, chord))) > 300);
// The last 10 s of the arc are exempt: the correction pulled the icon back
// over them, and keeping them would fold the line back on itself.
for (const point of arc.slice(0, -100)) {
const off = distanceToPath(point, between);
assert.ok(off < 50, `drawn arc point ${off.toFixed(0)} m from the trail`);
}
assertNoSpur(line, 'trail');
assertNoSpur(h.head(), 'head');
} finally {
h.restore();
}
});
test('a fresh report after a hold resumes with no backward spur', () => {
const fixes = [0, 1, 2, 3].map((i) =>
fix(T0 + i * 15 * SEC, 40, -100 + i * 0.04, 10_000, 230, 90),
);
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -100 + 0.12,
velocity: 230,
true_track: 90,
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 45 * SEC,
};
const start = T0 + 45 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// The contact coasts five minutes, then holds; recording stops while it
// holds.
h.run(start + 320 * SEC);
const held = h.bodyPoints().length;
h.run(start + 360 * SEC);
assert.equal(h.bodyPoints().length, held, 'the trail grew during a hold');
// A fresh report, a little north of the track, ahead of the display.
const display = start + 360 * SEC - RENDER_DELAY_SEC * SEC;
// Just past where it held: five minutes and ten seconds at 230 m/s.
const aheadDeg = (230 * 310) / (111_320 * Math.cos((40 * Math.PI) / 180));
const fresh = fix(
display + 10 * SEC,
40.01,
-100 + 0.12 + aheadDeg,
10_000,
230,
80,
);
fixes.push(fresh);
info.lastContactEpochMs = fresh.epochMs;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(fresh.position),
fresh.epochMs,
);
h.run(start + 390 * SEC);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), fresh.position) >= 0);
} finally {
h.restore();
}
});
test('with the head hidden the body keeps advancing, and untracking clears the recording', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 5 * SEC);
const before = h.bodyPoints().at(-1);
// Cockpit view: the head is hidden and its callback never runs.
h.run(start + 30 * SEC, { readHead: false });
// The body's end keeps following the icon; straight stretches are thinned.
const end = h.bodyPoints().at(-1);
assert.ok(
Cesium.Cartesian3.distance(end, before) > 1_000,
'the body stopped advancing',
);
assert.ok(Cesium.Cartesian3.distance(end, h.drawn.at(-1)) < 300);
h.tracking._clearTrail();
assert.equal(h.flightState._trailFixes.length, 0);
// A new track of the same plane starts from reports only.
h.tracking._startTrail(ICAO);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
test('a live fix with no altitude breaks the line and puts no point at an invented height', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const invented = Cesium.Cartesian3.fromDegrees(-122.38, 37.71, 10_000);
h.tracking._appendTrailFix(invented, T0 + 95 * SEC, { nullAltitude: true });
const later = Cesium.Cartesian3.fromDegrees(-122.39, 37.715, 1400);
h.tracking._appendTrailFix(later, T0 + 100 * SEC);
h.run(T0 + 100 * SEC + RENDER_DELAY_SEC * SEC + SEC, { frameMs: 500 });
for (const point of h.bodyPoints())
assert.ok(Cesium.Cartographic.fromCartesian(point).height < 5_000);
// The reports on either side of it are not joined.
const segments = h.body();
const holding = (p) =>
segments.findIndex((segment) =>
segment.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(holding(fixes[3].position), holding(later));
} finally {
h.restore();
}
});
test('with a 3D model as the visual, the trail stops outside the model and the head runs from there to the plane', () => {
const { fixes, info } = departure();
info.turnRateDps = 0;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const envelopeM = 300;
const h = harness({ fixes, info, nowMs: start, envelopeM });
try {
h.tracking._startTrail(ICAO);
h.run(start + 30 * SEC);
const icon = h.drawn.at(-1);
const body = h.bodyPoints();
const head = h.head();
assert.ok(head.length >= 2, 'no head is drawn');
assert.ok(Cesium.Cartesian3.distance(head.at(-1), icon) < 0.01);
// No trail point inside the model, and the head starts where the body ends.
for (const point of body)
assert.ok(Cesium.Cartesian3.distance(point, icon) >= envelopeM - 1);
assert.ok(Cesium.Cartesian3.distance(head[0], body.at(-1)) < 0.01);
// Body and head together follow where the icon was drawn.
const line = body.concat(head);
for (const point of h.drawn) {
const off = distanceToPath(point, line);
assert.ok(off < 35, `drawn icon ${off.toFixed(0)} m off the trail`);
}
} finally {
h.restore();
}
});
test('a report the display already passed replaces the guess drawn after it, with no spike', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 40 * SEC);
const displayMs = T0 + 130 * SEC;
// A report for 10 s before the display time, 400 m inside the drawn arc.
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 120 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
T0 + 120 * SEC,
Cesium.Math.toDegrees(c.latitude) - 400 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
250,
);
fixes.push(report);
info.true_track = 250;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
h.run(start + 55 * SEC);
const recorded = h.flightState._trailFixes.filter((entry) => entry.drawn);
// Nothing recorded between the report and the end of the ease.
assert.deepEqual(
recorded
.filter(
(entry) =>
entry.timeMs > report.epochMs && entry.timeMs < displayMs + 900,
)
.map((entry) => entry.timeMs - T0),
[],
);
const line = h.bodyPoints();
assert.ok(indexOfPoint(line, report.position) >= 0);
assertNoSpur(line, 'trail');
} finally {
h.restore();
}
});
test('a history report at an invented height is seeded with no position, and nothing is recorded next to it', () => {
const { fixes, info } = departure();
fixes[3].inventedHeight = true;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const seeded = h.flightState._trailFixes.find(
(entry) => entry.timeMs === fixes[3].epochMs,
);
assert.equal(seeded.position, null);
assert.equal(seeded.nullAltitude, true);
h.run(start + 10 * SEC);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
// A turboprop at FL210 flying east out of receiver coverage: its last
// position is at T0 + 30 s, and contact goes on for four more minutes.
function outOfCoverage() {
const fixes = [
fix(T0, 43, -108.1, 6_400, 140, 90),
fix(T0 + 30 * SEC, 43, -108.05, 6_400, 140, 90),
];
const info = {
altitude: 6_400,
renderAltitudeM: 6_400,
rawLat: 43,
rawLon: -108.05,
velocity: 140,
true_track: 90,
callsign: 'TEST7',
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 30 * SEC + 240 * SEC,
};
return { fixes, info };
}
/** Display time (ms) `afterSec` past the last report. */
const pastReport = (afterSec) =>
T0 + 30 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
test('a plane that stops reporting keeps moving dimmed as STALE from its first missed update, then stops as SIGNAL LOST', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(30));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
assert.equal(h.tracking._trackedIconColor().alpha, 1);
// The first missed update.
h.flightState.records.missingPolls.set(ICAO, 1);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.match(h.tracking._trackedLabelText(ICAO), /STALE/);
// Drawn with the fleet's stale fade.
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
// Still moving well past 90 s.
h.run(pastReport(200));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-11)) > 100,
);
// The coast ends 300 s past the report; the plane stops there.
h.run(pastReport(310));
const recorded = h.flightState._trailFixes.filter((e) => e.drawn).length;
h.run(pastReport(330));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-50)) < 0.01,
);
assert.equal(
h.flightState._trailFixes.filter((e) => e.drawn).length,
recorded,
'the trail kept recording a stopped plane',
);
// 330 s of display past the report is 330 s + the display delay of wall time.
const minutes = Math.floor((330 + RENDER_DELAY_SEC) / 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${minutes} min`,
dimmed: true,
});
assert.match(
h.tracking._trackedLabelText(ICAO),
new RegExp(`SIGNAL LOST · ${minutes} min`),
);
} finally {
h.restore();
}
});
test('a plane held at its last report shows SIGNAL LOST in seconds under a minute', () => {
// Its last report is 100 m above a known ground height: it holds there.
const { fixes, info } = outOfCoverage();
const h = harness({
fixes,
info,
nowMs: pastReport(-5),
ground: () => 6_300,
});
try {
h.tracking._startTrail(ICAO);
h.flightState.records.missingPolls.set(ICAO, 1);
h.run(pastReport(10));
assert.ok(10 + RENDER_DELAY_SEC < 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${10 + RENDER_DELAY_SEC} s`,
dimmed: true,
});
} finally {
h.restore();
}
});
test('a report arriving 200 s into a coast: the plane eases onto it and the trail drops what was drawn after it', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(200));
// Heard again: the report is for 10 s before the display time, 2 km north
// of where the coast put it.
const at = T0 + 30 * SEC + 190 * SEC;
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(at)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
at,
Cesium.Math.toDegrees(c.latitude) + 2_000 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
140,
90,
);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
assert.deepEqual(
h.flightState._trailFixes.filter(
(e) => e.drawn && e.timeMs > report.epochMs,
),
[],
);
const from = h.drawn.length;
h.run(pastReport(203));
const after = h.drawn.slice(from - 1);
for (let i = 1; i < after.length; i++)
assert.ok(
Cesium.Cartesian3.distance(after[i], after[i - 1]) < 400,
'the plane jumped instead of easing',
);
// Eased onto the path from the report: 13 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(after.at(-1), report.position);
assert.ok(
Math.abs(off - 140 * 13) < 150,
`${off.toFixed(0)} m from the report`,
);
} finally {
h.restore();
}
});
test('a tracked plane that stops reporting: STALE from the first missed poll, SIGNAL LOST after the five-minute coast, kept at 9 min, removed at 10 min', () => {
const { fixes, info } = outOfCoverage();
const fixMs = fixes.at(-1).epochMs;
// Contact stops with the position: the plane left coverage.
info.lastContactEpochMs = fixMs;
info.newestFixMs = fixMs;
const h = harness({ fixes, info, nowMs: fixMs + 5 * SEC });
try {
h.tracking._startTrail(ICAO);
const records = h.flightState.records;
const polls = new Map();
let removedAtSec = null;
// A poll every 10 s that lists the plane without a position.
for (let afterSec = 10; afterSec <= 660; afterSec += 10) {
h.run(fixMs + afterSec * SEC, { frameMs: 500, readHead: false });
const result = records.absence(ICAO, {
complete: true,
retainIds: new Set([ICAO]),
likelyLanded: false,
tracked: true,
});
if (result === 'remove') {
removedAtSec = afterSec;
break;
}
polls.set(afterSec, {
cue: h.tracking._trackedStaleCue(ICAO),
drawn: Cesium.Cartesian3.clone(h.drawn.at(-1)),
});
}
// Wall time when the display reaches the end of the coast.
const coastEndSec = 300 + RENDER_DELAY_SEC;
for (const [afterSec, { cue }] of polls) {
if (afterSec <= coastEndSec) {
assert.deepEqual(cue, { text: 'STALE', dimmed: true }, `${afterSec} s`);
} else {
assert.deepEqual(
cue,
{
text: `SIGNAL LOST · ${Math.floor(afterSec / 60)} min`,
dimmed: true,
},
`${afterSec} s`,
);
}
}
const moved = (a, b) =>
Cesium.Cartesian3.distance(polls.get(a).drawn, polls.get(b).drawn);
// Moving through the coast, still at its last speed at four minutes.
assert.ok(Math.abs(moved(230, 240) - 140 * 10) < 20);
// Stopped once the coast has ended.
const after = Math.ceil(coastEndSec / 10) * 10;
assert.ok(moved(after, 540) < 0.01);
assert.equal(polls.get(300).cue.text, 'STALE');
assert.deepEqual(polls.get(540).cue, {
text: 'SIGNAL LOST · 9 min',
dimmed: true,
});
assert.equal(removedAtSec, 600);
} finally {
h.restore();
}
});
test('a repeated old fix counts as a missed update for the tracked plane', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
// The feed re-sends the last position: its fix time did not advance.
h.flightState.records.fixRepeated.add(ICAO);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs;
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(360));
const stop = Cesium.Cartesian3.clone(h.drawn.at(-1));
assert.ok(Cesium.Cartesian3.distance(stop, h.drawn.at(-100)) < 0.01);
const kept = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => Cesium.Cartesian3.clone(e.position));
// The thinned straight coast, recorded up to where the plane stopped.
assert.ok(kept.length >= 2);
assert.ok(Cesium.Cartesian3.distance(kept.at(-1), stop) < 300);
// Heard again, for 20 s before the display time: the plane flew on, a
// little north of its old heading.
const at = T0 + 30 * SEC + 340 * SEC;
const lonDeg =
-108.05 + (140 * 340) / (111_320 * Math.cos((43 * Math.PI) / 180));
const report = fix(at, 43 + 1_000 / 111_320, lonDeg, 6_400, 140, 90);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// Nothing was recorded after the stop, so nothing is dropped.
const after = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => e.position);
assert.equal(after.length, kept.length);
kept.forEach((p, i) =>
assert.ok(Cesium.Cartesian3.distance(p, after[i]) < 0.01),
);
const from = h.drawn.length;
h.run(pastReport(365));
const eased = h.drawn.slice(from - 1);
const jump = Cesium.Cartesian3.distance(eased[0], eased.at(-1));
for (let i = 1; i < eased.length; i++)
assert.ok(
Cesium.Cartesian3.distance(eased[i], eased[i - 1]) < jump / 2,
'the plane jumped instead of easing',
);
// On the path from the report: 25 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(eased.at(-1), report.position);
assert.ok(Math.abs(off - 140 * 25) < 150, `${off.toFixed(0)} m`);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), report.position) >= 0);
} finally {
h.restore();
}
});
+83 -12
View File
@@ -5,10 +5,14 @@ import {
speedRamp,
courseBetweenCartesians,
COURSE_HOLD_SPEED_MPS,
floorCoastedPosition,
staleCoastLimitSeconds,
staleCoastSecondsFrom,
staleHoldsForFix,
arcOffsetEnu,
courseSlewCapDps,
limitCourseStep,
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
@@ -141,23 +145,86 @@ export function createMotion({
);
}
// Continue through fresh adsb.lol contacts even when the position epoch has
// not advanced, but keep an absolute stale-feed drift ceiling.
// Coast on the last speed and heading for five minutes past the newest
// fix, then stop, dimmed as SIGNAL LOST. A contact last heard on or near
// the ground holds there instead, and a coast never goes below the ground.
const { coastSec } = _coastPlan(newest, info, elapsedSec);
const cachedGroundFloor = services.groundFloor?.cachedGroundFloor;
_extrapolateFix(
newest,
info,
coastSec,
out,
(info && info.turnRateDps) || 0,
);
return coastSec > 0 ? floorCoastedPosition(out, cachedGroundFloor) : out;
}
/**
* How far a contact coasts past its newest fix at a display time
* `elapsedSec` after it: up to five minutes, or not at all for a contact
* that holds at a report on or near the ground. The hold decision is made once
* per report and kept on it.
* @param {{epochMs?: number, time: Cesium.JulianDate}} newest
* @param {object|undefined} info - The contact's record.
* @param {number} elapsedSec
* @returns {{coastSec: number, holds: boolean}}
*/
function _coastPlan(newest, info, elapsedSec) {
const coastLimitSec = staleCoastLimitSeconds({
// Captured at poll normalization so the fleet tick does not allocate a
// Date for every aircraft twelve times per second.
// `epochMs` is captured once when the poll is normalized. Avoid allocating
// a Date per aircraft on every fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Five minutes of flight after the last report, as flights.
minimumSec: 300,
maximumSec: 300,
});
return _extrapolateFix(
const holds = staleHoldsForFix(newest, {
onGround: info?.onGround,
cachedGroundFloor: services.groundFloor?.cachedGroundFloor,
});
return {
coastSec: staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}),
holds,
};
}
/**
* Whether a contact has stopped where its coast ended (or held at its last
* report) with no newer report, and since when it has had none.
* @param {string} icao24
* @returns {{reportEpochMs: number, holds: boolean}|null} Null while a
* report is ahead of the display time or the contact is still coasting.
*/
function _signalLost(icao24) {
const newest = flightState._positionHistory.get(icao24)?.at(-1);
if (!newest) return null;
const newestMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
const elapsedSec =
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
if (!(elapsedSec > 0)) return null;
const { coastSec, holds } = _coastPlan(
newest,
info,
Math.min(elapsedSec, coastLimitSec),
out,
(info && info.turnRateDps) || 0,
flightState.records.data.get(icao24),
elapsedSec,
);
if (!(elapsedSec > coastSec)) return null;
return {
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: newestMs,
holds,
};
}
/**
@@ -172,9 +239,12 @@ export function createMotion({
*/
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.speedMps) || 0;
const speed = Math.min(
Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.speedMps) || 0,
COAST_MAX_SPEED_MPS,
);
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.track) || 0;
@@ -514,6 +584,7 @@ export function createMotion({
}
return {
_deadReckon,
_signalLost,
_extrapolateFix,
_isTrackWarmingUp,
_trackedFocusSizePx,
+4 -1
View File
@@ -172,7 +172,7 @@ export const TRAIL_COLOR = '#FFB800';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MAX_POINTS = 2000;
// ---------------------------------------------------------------------------
// Render-behind smoothing (mirrors flights.js): the fleet renders at
@@ -271,3 +271,6 @@ export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500;
* synchronously (single primitive). */
export const IR_RELOAD_BATCH = 40;
/** Alpha of a contact that has missed an update or lost its signal: the stale fade, for the fleet and the tracked aircraft alike. */
export const STALE_ALPHA = 0.45;
+4
View File
@@ -6,4 +6,8 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
export const MISSING_POLL_LIMIT = 3;
/** The tracked contact is kept this long after its newest fix: five minutes
* coasting, then five stopped as SIGNAL LOST. */
export const TRACKED_RETENTION_MS = 10 * 60_000;
export const ERROR_BACKOFF_INTERVAL = 20000;
+61 -3
View File
@@ -6,6 +6,7 @@ import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
TRACKED_RETENTION_MS,
} from './recordPolicy.js';
/** Portable military aircraft metadata and bounded missing-poll retention. */
@@ -14,9 +15,44 @@ export class MilitaryFlightRecords {
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
this.data = new Map();
this.missingPolls = new Map();
// Contacts whose last poll re-sent a fix no newer than the one before.
this.fixRepeated = new Set();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
this.geoidNCache = new Map();
this.geoidReady = false;
}
/**
* Start reading a snapshot taken at `observedAtMs`. A fix re-sent in a
* newer snapshot marks its contact as not updated; the same snapshot read
* again does not.
* @param {number} observedAtMs
*/
beginSnapshot(observedAtMs) {
this.snapshotAdvanced =
!Number.isFinite(observedAtMs) ||
!Number.isFinite(this.lastSnapshotMs) ||
observedAtMs > this.lastSnapshotMs;
if (Number.isFinite(observedAtMs))
this.lastSnapshotMs = Math.max(
this.lastSnapshotMs ?? -Infinity,
observedAtMs,
);
}
/** Note a received fix: the newest fix time, and whether it advanced. */
_noteFix(id, prevMeta, fixEpochMs) {
const prevFixMs = prevMeta?.newestFixMs;
// A fix counts as new only when it moves on by a second: feeds that
// derive fix time from receipt (readsb `now − seen_pos`, 0.1 s) jitter.
if (!Number.isFinite(prevFixMs) || fixEpochMs >= prevFixMs + 1000) {
this.fixRepeated.delete(id);
return fixEpochMs;
}
if (this.snapshotAdvanced) this.fixRepeated.add(id);
return prevFixMs;
}
receive(
aircraft,
{ observedAtMs, floorWarmPoints: _floorWarmPoints, modelOwnsVisual },
@@ -88,6 +124,10 @@ export class MilitaryFlightRecords {
// already uses (which also carries the on-ground 0 m / last-known-baro
// case), so the two never disagree on the "no data yet" case.
let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM;
// No altitude for an airborne contact: the height is the airborne
// default, invented. The tracked trail keeps no point there.
const renderAltitudeInvented =
pickedAltM == null && !onGround && aircraft.baroAltitudeM == null;
// Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only
// AIRBORNE contact near steep terrain can compute a render height
// BELOW the local surface (no alt_geom; baro+N carries QNH error
@@ -127,9 +167,11 @@ export class MilitaryFlightRecords {
// Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold
// last-known-good (bounded by the layer's eviction, which deletes the entry).
const stickyType = stickyText(type, prevMeta?.type);
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
const meta = {
sourceReference: aircraft.reference,
observedReceiptMs: Date.now(),
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
callsign: stickyText(callsign, prevMeta?.callsign),
type: stickyType,
// Type outranks category automatically inside classifyAircraft.
@@ -146,6 +188,7 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
renderAltitudeInvented,
// A poll that omits track/speed holds the last values, like the civil
// records; 0 only until one is known. A grounded row without a speed
// reads 0, so a parked plane never coasts at its landing speed.
@@ -184,10 +227,25 @@ export class MilitaryFlightRecords {
prevMeta,
meta,
groundFlipped,
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
fixEpochMs,
};
}
absence(id, { complete, retainIds, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
const misses = (this.missingPolls.get(id) || 0) + 1;
// The tracked contact has missed an update: it is stale from the first
// one, and kept (so the camera keeps its target) until ten minutes after
// its newest fix, whatever the snapshot says. With no fix time known it
// takes the ordinary limits below.
const meta = this.data.get(id);
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
if (tracked && Number.isFinite(fixMs)) {
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
this.missingPolls.set(id, misses);
return 'stale';
}
this.missingPolls.delete(id);
return 'remove';
}
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
@@ -196,7 +254,6 @@ export class MilitaryFlightRecords {
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
const misses = (this.missingPolls.get(id) || 0) + 1;
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
this.missingPolls.set(id, misses);
@@ -208,6 +265,7 @@ export class MilitaryFlightRecords {
forget(id) {
this.data.delete(id);
this.missingPolls.delete(id);
this.fixRepeated.delete(id);
this.geoidNCache.delete(id);
}
}
+95
View File
@@ -174,3 +174,98 @@ test('military records hold the last track and speed when a poll omits them', ()
assert.equal(parked.meta.speedMps, 0);
assert.equal(parked.meta.track, 90);
});
test('an airborne military contact reported with no altitude is marked as drawn at an invented height', () => {
const records = store();
const none = records.receive(
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
context(),
);
assert.equal(none.meta.renderAltitudeInvented, true);
const known = records.receive(
observation({ positionTimeMs: 1700000005000 }),
context(),
);
assert.equal(known.meta.renderAltitudeInvented, false);
const parked = store().receive(
observation({ baroAltitudeM: null, onGround: true }),
context(),
);
assert.equal(parked.meta.renderAltitudeInvented, false);
});
test('the tracked military contact is stale from its first missed update and kept until ten minutes after its newest fix', () => {
const realNow = Date.now;
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
let clock = fixMs + 5_000;
Date.now = () => clock;
try {
for (const snapshot of [
{ complete: true },
{ complete: true, retainIds: new Set(['aaa001']) },
{ complete: false },
]) {
const records = store();
records.receive(
observation({ positionTimeMs: fixMs }),
context({ observedAtMs: fixMs + 1_000 }),
);
const absent = (tracked) =>
records.absence('aaa001', {
...snapshot,
likelyLanded: false,
tracked,
});
clock = fixMs + 15_000;
assert.equal(absent(true), 'stale');
assert.equal(records.missingPolls.get('aaa001'), 1);
clock = fixMs + 9 * 60_000;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000 - 1;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000;
assert.equal(absent(true), 'remove');
assert.equal(records.missingPolls.has('aaa001'), false);
}
const records = store();
records.receive(
observation({ positionTimeMs: fixMs }),
context({ observedAtMs: fixMs + 1_000 }),
);
clock = fixMs + 15_000;
const untracked = (snapshot) =>
records.absence('aaa001', { ...snapshot, likelyLanded: false });
assert.equal(untracked({ complete: false }), 'retain');
assert.deepEqual(
[1, 2, 3].map(() => untracked({ complete: true })),
['stale', 'stale', 'remove'],
);
} finally {
Date.now = realNow;
}
});
test('a repeated military fix time marks the contact as not updated; removal is unchanged', () => {
const records = store();
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
const poll = (snapshotMs, positionTimeMs) => {
records.beginSnapshot(snapshotMs);
records.receive(
observation({ positionTimeMs }),
context({ observedAtMs: snapshotMs }),
);
};
poll(fixMs + 2_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), false);
// adsb.lol re-sends the last position for up to a minute.
poll(fixMs + 12_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), true);
assert.equal(records.missingPolls.has('aaa001'), false);
poll(fixMs + 12_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), true);
poll(fixMs + 22_000, fixMs + 10_000);
assert.equal(records.fixRepeated.has('aaa001'), false);
poll(fixMs + 32_000, fixMs + 10_000);
records.forget('aaa001');
assert.equal(records.fixRepeated.has('aaa001'), false);
});
+25 -1
View File
@@ -52,6 +52,7 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
STALE_ALPHA,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -640,6 +641,19 @@ export function createRendering({
/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The
* tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */
/** Fade the tracked 3D model like the billboard while the plane is stale. */
function _applyTrackedModelFade(model) {
const alpha = parts.tracking._trackedStaleCue?.(flightState._trackedIcao)
?.dimmed
? STALE_ALPHA
: 1;
if (model.color?.alpha === alpha) return;
model.color = (
flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR
).withAlpha(alpha);
}
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
@@ -787,6 +801,7 @@ export function createRendering({
flightState._trackedModel.modelMatrix,
spec.headingOffsetDeg,
);
_applyTrackedModelFade(flightState._trackedModel);
if (!flightState._trackedModel.ready) return;
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
@@ -814,6 +829,13 @@ export function createRendering({
*/
function _fleetTick() {
// Every frame, shown or not: the tracked trail records where its icon is
// drawn and moves its body on (the head callback only reads the result),
// and the tracked label's stale cue follows the clock.
if (flightState._viewer) {
parts.tracking._trailTick?.();
parts.tracking._staleCueTick?.();
}
if (
!flightState._viewer ||
!flightState._billboardCollection ||
@@ -989,7 +1011,9 @@ export function createRendering({
flightState._cockpitContactMode && !isCockpitNear
? 1
: _militaryBillboardScale(icao24),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseAlpha: flightState.records.missingPolls.get(icao24)
? STALE_ALPHA
: 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
+21 -5
View File
@@ -4,6 +4,7 @@ import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
staleHoldsForFix,
synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
@@ -29,6 +30,7 @@ export function createMilitarySnapshotRenderer({
return function applySnapshot(snapshot, viewer) {
const currentIcaos = new Set();
const receiptNowMs = snapshot.observedAtMs;
records.beginSnapshot(snapshot.observedAtMs);
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
// clamp — collected during the loop (low airborne contacts only),
// batch-resolved once after it. Never a fetch inside the loop.
@@ -83,6 +85,9 @@ export function createMilitarySnapshotRenderer({
position: position.clone(),
velocity: meta.speedMps,
track: meta.track,
// Drawn at an invented height: a trail seeded from history keeps no
// position for it.
inventedHeight: meta.renderAltitudeInvented === true,
});
if (history.length > POSITION_HISTORY_LIMIT) {
history.shift();
@@ -95,26 +100,36 @@ export function createMilitarySnapshotRenderer({
// (owner): ground traffic appends too — taxi history stays live
// after touchdown; _appendTrailFix floors grounded positions at
// the surface so the ground leg drapes instead of diving.
if (isTracked) tracking._appendTrailFix(position.clone());
if (isTracked)
tracking._appendTrailFix(position.clone(), fixEpochMs, {
nullAltitude: meta.renderAltitudeInvented === true,
});
} else {
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
if (!modelOwnsGroundVisual) {
liftRepeatedGroundFix(newest, position, meta.onGround);
}
// A forward synthetic fix follows the stale rules (none while the
// contact holds at its report, a coast capped from the real report,
// never below the ground) and is a motion aid, never a trail report.
const kinematicsChanged =
newest.velocity !== meta.speedMps || newest.track !== meta.track;
if (kinematicsChanged) {
const holds = staleHoldsForFix(newest, {
onGround: meta.onGround,
cachedGroundFloor,
});
if (kinematicsChanged && !holds) {
const synthetic = synthesizeForwardKinematicsFix(newest, {
epochMs: Date.now(),
velocity: meta.speedMps,
track: meta.track,
turnRateDps: meta.turnRateDps,
cachedGroundFloor,
});
if (synthetic) {
history.push(synthetic);
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
meta.turnRateDps = turnRateFromFixHistory(history);
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
}
}
}
@@ -228,8 +243,8 @@ export function createMilitarySnapshotRenderer({
const bbStale = flightState._billboards.get(icao24);
if (bbStale) bbStale.position = liftedPos;
}
// Round 4: sample the RENDERED mesh for those cells (one-shot per cell,
// budget-capped, viewer-proximate, google-3d regime only), excluding
// Round 4: sample the RENDERED mesh for those cells (a few probes per
// cell, budget-capped, viewer-proximate, google-3d regime only), excluding
// this layer's own billboards/models from the probe.
{
const viewerCarto =
@@ -261,6 +276,7 @@ export function createMilitarySnapshotRenderer({
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
tracked: icao24 === flightState._trackedIcao,
});
if (absence === 'retain') continue;
if (absence === 'stale') {
+152
View File
@@ -0,0 +1,152 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMilitarySnapshotRenderer } from './snapshotRenderer.js';
// A poll that repeats a contact's last position with new speed or course
// must not move it past where the stale rules allow, and never adds a
// report to the tracked trail.
const ICAO = '00a005';
const SEC = 1000;
/** Any property is a no-op function unless given. */
function stub(overrides = {}) {
return new Proxy(overrides, {
get: (target, key) => (key in target ? target[key] : () => undefined),
});
}
function harness({ ground = () => null }) {
const appended = [];
const history = [];
const flightState = {
_trackedIcao: ICAO,
_trackedEntity: null,
_positionHistory: new Map([[ICAO, history]]),
_billboards: new Map([[ICAO, { position: new Cesium.Cartesian3() }]]),
_billboardCollection: stub(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: stub(),
_displayFloorState: new Map(),
_models: new Map(),
_viewer: null,
_cockpitContactMode: false,
records: { data: new Map() },
};
let meta = null;
let fixEpochMs = 0;
const records = stub({
receive: () => {
flightState.records.data.set(ICAO, meta);
return { prevMeta: meta, meta, groundFlipped: false, fixEpochMs };
},
absence: () => 'retain',
});
const apply = createMilitarySnapshotRenderer({
flightState,
records,
militaryRegistry: stub(),
groundFloor: stub({ cachedGroundFloor: ground }),
meshFloor: stub(),
rendering: stub({
_modelOwnsVisual: () => false,
_fleetBillboardColor: () => Cesium.Color.WHITE,
}),
tracking: stub({
_militaryLayerSuppresses: () => false,
_appendTrailFix: (position, epochMs) =>
appended.push({ position, epochMs }),
}),
motion: stub(),
enrichment: stub(),
queries: stub({ _likelyLanded: () => false }),
});
return {
history,
appended,
poll(nextMeta, epochMs) {
meta = nextMeta;
fixEpochMs = epochMs;
apply({ records: [{ id: ICAO }], complete: true }, null);
},
};
}
const lightAircraft = (velocity, track) => ({
rawLat: 38.1436,
rawLon: -122.5561,
renderAltitudeM: 10,
altitudeFt: 130,
speedMps: velocity,
track,
onGround: false,
wasAirborne: true,
klass: 'light',
});
test('military: a low contact whose position repeats while its speed changes is not moved and adds nothing to the trail', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({ ground: () => -30 });
h.poll(lightAircraft(36.4, 63), t0);
const first = h.history[0].position;
for (const [i, velocity] of [30, 22, 14, 9].entries()) {
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(velocity, 63 + i * 20), t0);
}
assert.equal(h.history.length, 1, 'a synthetic fix moved a holding contact');
assert.equal(h.history[0].position, first);
assert.equal(h.appended.length, 1, 'only the real report joins the trail');
});
test('military: a cruising contact whose position repeats coasts no further than the stale cap, and the synthetic fix is not a report', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
const cruise = (velocity, track) => ({
rawLat: 36,
rawLon: -115,
renderAltitudeM: 9_000,
altitudeFt: 29_500,
speedMps: velocity,
track,
onGround: false,
wasAirborne: true,
klass: 'jet',
});
h.poll(cruise(220, 90), t0);
const report = h.history[0].position;
t.mock.timers.tick(300 * SEC);
h.poll(cruise(200, 100), t0);
assert.equal(h.history.length, 2);
const moved = Cesium.Cartesian3.distance(report, h.history[1].position);
assert.ok(
moved <= 220 * 300 + 1,
`the synthetic fix coasted ${moved.toFixed(0)} m`,
);
assert.equal(h.appended.length, 1, 'the synthetic fix joined the trail');
});
test('military: a report drawn at an invented height says so on its history fix', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll({ ...lightAircraft(60, 90), renderAltitudeInvented: true }, t0);
h.poll({ ...lightAircraft(60, 90), rawLon: -122.55 }, t0 + 10 * SEC);
assert.equal(h.history[0].inventedHeight, true);
assert.equal(h.history[1].inventedHeight, false);
});
test('military: with the ground unknown, a low barometric altitude alone does not hold a contact', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll(lightAircraft(36.4, 63), t0);
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(30, 80), t0);
assert.equal(h.history.length, 2);
assert.equal(h.history[0].staleHolds, false);
assert.equal(h.appended.length, 1);
});
+199
View File
@@ -0,0 +1,199 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
import { RENDER_DELAY_SEC } from './policy.js';
const SEC = 1000;
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
const flightState = {
records: { data: new Map([['00a003', info]]) },
_positionHistory: new Map([['00a003', fixes]]),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchEnu: new Cesium.Matrix4(),
_scratchRenderTime: new Cesium.JulianDate(),
now: () => nowMs,
};
const motion = createMotion({
flightState,
services: {
groundFloor: { cachedGroundFloor: ground },
camera: {},
focus: {},
},
parts: {},
});
return motion;
}
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
test('a military contact stale after a low approach holds at its last report', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const ground = 1_590; // An airbase, ellipsoidal.
const fixes = [
fix(t0, 35.03, -106.58, ground + 300, 75, 260),
fix(t0 + 10 * SEC, 35.03, -106.588, ground + 40, 70, 260),
];
const info = {
altitudeFt: 5_350,
renderAltitudeM: ground + 40,
rawLat: 35.03,
rawLon: -106.588,
speedMps: 70,
track: 260,
onGround: false,
};
for (const afterSec of [10, 60, 200]) {
const motion = motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
ground: () => ground,
});
const drawn = motion._deadReckon('00a003', new Cesium.Cartesian3());
assert.ok(Cesium.Cartesian3.distance(drawn, fixes[1].position) < 0.01);
}
});
test('a cruising military contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 36, -115, 9_000, 220, 90),
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 220, 90),
];
const info = {
altitudeFt: 29_500,
renderAltitudeM: 9_000,
rawLat: 36,
rawLon: -114.975,
speedMps: 220,
track: 90,
onGround: false,
lastContactEpochMs: t0 + 10 * SEC + 300 * SEC,
};
const at = (afterSec) =>
motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
})._deadReckon('00a003', new Cesium.Cartesian3());
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
assert.ok(near(d(at(45)), 220 * 45));
assert.ok(near(d(at(250)), 220 * 250), 'still moving at 250 s');
assert.ok(near(d(at(400)), 220 * 300), 'stopped at 300 s');
});
test('a bad military speed coasts no faster than the 1,500 kt continuity limit', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 36, -115, 9_000, 2_000, 90),
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 2_000, 90),
];
const info = {
altitudeFt: 29_500,
renderAltitudeM: 9_000,
rawLat: 36,
rawLon: -114.975,
speedMps: 2_000,
track: 90,
onGround: false,
};
const drawn = motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC,
})._deadReckon('00a003', new Cesium.Cartesian3());
const limitMps = (TRAIL_CONTINUITY_MAX_SPEED_KT * 1852) / 3600;
const d = Cesium.Cartesian3.distance(drawn, fixes[1].position);
assert.ok(Math.abs(d - limitMps * 300) < 1_500, `${d.toFixed(0)} m`);
});
test('a holding military contact draws no head and its trail ends at the last report', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [0, 1, 2].map((i) =>
fix(t0 + i * 10 * SEC, 35.03, -106.57 - i * 0.008, 1_700, 70, 260),
);
const svc = {
...Object.fromEntries(
[
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
].map((name) => [name, {}]),
),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: () => null,
floorAltitudeM: (alt) => alt,
},
};
const calls = [];
svc.trails = {
createTrail: () => ({
setSegments: (segments) => calls.push(segments.flat()),
setPositions: (positions) => calls.push(positions.slice()),
setVisible() {},
clear() {},
destroy() {},
}),
};
const flightState = createFlightState({ services: svc, source: {} });
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add: (definition) => (added.push(definition), definition),
remove() {},
},
};
flightState._trackedIcao = '00a003';
flightState.now = () => t0 + 20 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC;
flightState._positionHistory.set('00a003', fixes);
const last = Cesium.Cartesian3.clone(fixes[2].position);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => last,
_trackedDisplayPosition: () => last,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
tracking._startTrail('00a003');
assert.equal(calls.at(-1).length, 3);
const head = added.find((d) =>
String(d.id).startsWith('gev-trail:mil-head-'),
);
assert.deepEqual(head.polyline.positions.getValue(), []);
});
+25 -2
View File
@@ -178,9 +178,32 @@ export function createFlightState({ source, services }) {
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
/** @type {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean, breakBefore: boolean, drawn?: boolean}[]}
* Chronological tracked-aircraft trail (oldest first): reports, and
* (`drawn`) where the icon was drawn between them; `breakBefore` starts a
* new segment. */
flightState._trailPositions = [];
flightState._trailFixes = [];
/** @type {number} Body fixes drawn: those at or before the display time. */
flightState._trailBodyCount = 0;
/** @type {Cesium.Cartesian3|null} Where the tracked icon was last recorded into the trail. */
flightState._trailRecordPosition = null;
/** @type {number} Display time (epoch ms) of that recording. */
flightState._trailRecordMs = -Infinity;
/** @type {string|undefined} The stale cue last written into the tracked label (see `_staleCueTick`). */
flightState._trackedStaleCueText = undefined;
/** @type {{key: string, cue: {text: string, dimmed: boolean}}|null} This frame's tracked stale cue (see `_trackedStaleCue`). */
flightState._trackedStaleCueCache = null;
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
+286 -104
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
@@ -19,14 +19,33 @@ import {
TRACKED_MODEL_MAX_LOAD_FAILS,
TRACKED_MODEL_RETRY_BACKOFF_MS,
TRAIL_MAX_POINTS,
DR_CORRECTION_MS,
RENDER_DELAY_SEC,
TRAIL_COLOR,
BILLBOARD_SCALE,
GROUND_SCALE,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
STALE_ALPHA,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import {
drawTrailSegments,
historyFixEpochMs,
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
trailBackfillFixes,
trailBodyCount,
trailBodyCountOutsideModel,
trailBodySegments,
trailHeadStartFix,
trailRecordingBlocked,
TRAIL_RECORD_STEP_M,
TRAIL_RECORD_STEP_MS,
} from '../../data/trailContinuity.js';
const STALE_TRACKED_COLOR = TRACKED_ICON_COLOR.withAlpha(STALE_ALPHA);
export function createTracking({
flightState,
@@ -206,24 +225,98 @@ export function createTracking({
_setCockpitContactMode(active);
}
/**
* The tracked plane's stale cue and whether it is drawn with the stale fade.
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer
* report has come.
* - STALE, dimmed: it has missed an update and still coasts.
* - STALE, not dimmed: the whole feed is backing off.
* @param {string} icao24
* @returns {{text: string, dimmed: boolean}}
*/
function _trackedStaleCue(icao24) {
const records = flightState.records;
const misses = records.missingPolls.get(icao24) || 0;
const repeated = records.fixRepeated?.has(icao24) === true;
const backoff = flightState.feed._backoff === true;
// Computed once per frame for the label, the icon colour and the model
// fade; a poll between frames changes the key.
const frame = flightState._viewer?.scene?.frameState?.frameNumber;
const key = Number.isFinite(frame)
? `${frame}|${icao24}|${misses}|${repeated}|${backoff}`
: null;
const cached = flightState._trackedStaleCueCache;
if (key !== null && cached?.key === key) return cached.cue;
const cue = _computeTrackedStaleCue(
icao24,
misses > 0 || repeated,
backoff,
);
flightState._trackedStaleCueCache = key === null ? null : { key, cue };
return cue;
}
/** `_trackedStaleCue` without the per-frame cache. A repeated fix counts as missed. */
function _computeTrackedStaleCue(icao24, missed, backoff) {
const lost = parts.motion._signalLost(icao24);
if (lost && (missed || !lost.holds)) {
const sec = Math.max(
0,
Math.floor((flightState.now() - lost.reportEpochMs) / 1000),
);
const time =
sec < 60
? `${formatNumber(sec, { useGrouping: false })} ${unitLabel('s')}`
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
}
if (missed) return { text: t('STALE'), dimmed: true };
if (backoff) return { text: t('STALE'), dimmed: false };
return { text: '', dimmed: false };
}
/** The tracked billboard's colour: amber, faded while stale, clear while the 3D model is the visual. */
function _trackedIconColor() {
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
return AMBER_TRANSPARENT;
return _trackedStaleCue(flightState._trackedIcao).dimmed
? STALE_TRACKED_COLOR
: TRACKED_ICON_COLOR;
}
/**
* Re-word the tracked label when its stale cue changes (STALE, SIGNAL LOST,
* each new second or minute of it). Run from the layer's per-frame pass.
*/
function _staleCueTick() {
const icao24 = flightState._trackedIcao;
if (!icao24 || !flightState._trackedEntity) return;
const text = _trackedStaleCue(icao24).text;
if (text === flightState._trackedStaleCueText) return;
flightState._trackedStaleCueText = text;
_updateTrackedLabelModel(icao24);
}
/**
* Build the multi-line presentation text for the protected tracked host card.
* Lines: callsign, type/registration, operator/altitude.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata) the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* altitude/speed as live.
* The first line carries the stale cue (`_trackedStaleCue`): STALE while
* the plane coasts on dead reckoning after a missed update, SIGNAL LOST
* once the coast has ended, so last-known altitude/speed are never
* presented as live.
* @param {Object|null} info - Flight metadata from _flightData
* @param {string} icao24 - ICAO hex identifier (fallback display name)
* @returns {string} Newline-separated label text
*/
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ` · ${t('STALE')}`
: '';
const cue = _trackedStaleCue(icao24).text;
const stale = cue ? ` · ${cue}` : '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
parts.queries._toCleanText(info?.registration) ||
@@ -456,33 +549,112 @@ export function createTracking({
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
* Put one report into the tracked aircraft's trail, in time order, and
* refresh the rendered trail. Caller passes an owned (cloned) Cartesian3. A
* report that breaks from its neighbour starts a new segment (see
* trailContinuity).
* @param {Cesium.Cartesian3} position - The report's position.
* @param {number} [epochMs] - The report's time; defaults to the tracked
* aircraft's newest position-history fix, which every caller has just pushed.
* @param {{nullAltitude?: boolean}} [options] - `nullAltitude`: the report
* had no altitude, so its position is not kept and the line breaks there.
* A report for a time the display has already passed replaces what was
* drawn after it (see `insertTrailFix`).
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(_trailFloorPosition(position));
if (flightState._trailPositions.length > TRAIL_MAX_POINTS)
flightState._trailPositions.shift();
function _appendTrailFix(position, epochMs, { nullAltitude = false } = {}) {
const history = flightState._positionHistory.get(flightState._trackedIcao);
const timeMs = Number.isFinite(epochMs)
? epochMs
: historyFixEpochMs(history?.[history.length - 1]);
insertTrailFix(
flightState._trailFixes,
_trailFloorPosition(position),
timeMs,
TRAIL_MAX_POINTS,
{
nullAltitude,
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
settleMs: DR_CORRECTION_MS,
},
);
_refreshTrailDisplay();
}
/**
* Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That
* newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
* now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane.
* The cheap per-frame _trailHeadEntity segment bridges the last body point to the
* delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll
* cadence), never at motion cadence.
* Per-frame trail work, run from the layer's preRender pass whether or not
* the head is visible: record where the tracked icon is drawn (at most once
* a second or every 150 m, floored like the head) and move the body on as
* the display time passes reports. The head callback only reads the result.
*/
function _trailTick() {
if (!flightState._trackedIcao || !flightState._trail) return;
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
let changed = false;
if (
flightState._trailFixes.length &&
!parts.motion._isTrackWarmingUp() &&
!trailRecordingBlocked(flightState._trailFixes, displayMs)
) {
const drawn = parts.motion._trackedDisplayPosition(
flightState._trackedIcao,
);
const last = flightState._trailRecordPosition;
if (
drawn &&
(!last ||
displayMs - flightState._trailRecordMs >= TRAIL_RECORD_STEP_MS ||
Cesium.Cartesian3.distance(last, drawn) >= TRAIL_RECORD_STEP_M)
) {
flightState._trailRecordMs = displayMs;
flightState._trailRecordPosition = Cesium.Cartesian3.clone(
drawn,
flightState._trailRecordPosition ?? undefined,
);
changed = recordTrailPoint(
flightState._trailFixes,
_trailFloorPosition(Cesium.Cartesian3.clone(drawn)),
displayMs,
TRAIL_MAX_POINTS,
);
}
}
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
_refreshTrailDisplay();
}
/**
* Renders the trail BODY — the accumulated fixes at or before the delayed display
* time (now − RENDER_DELAY_SEC). Newer fixes are AHEAD of the icon, so drawing them
* would push the trail in FRONT of the plane. The cheap per-frame _trailHeadEntity
* segment bridges the last body point to the delayed dead-reckoned head, so the
* body rebuilds only on a real fix or when the display time passes one, never at
* motion cadence. A break lifts the pen: each segment is
* its own line, nothing between them.
*/
function _refreshTrailDisplay() {
if (!flightState._trail) return;
flightState._trail.setPositions(
flightState._trailPositions.length > 1
? flightState._trailPositions.slice(0, -1)
: flightState._trailPositions,
flightState._trailBodyCount = _trailBodyCountNow();
drawTrailSegments(
flightState._trail,
trailBodySegments(flightState._trailFixes, flightState._trailBodyCount),
);
}
/** Fixes at or before the time the aircraft is drawn (now − RENDER_DELAY_SEC),
* less the newest recorded points inside its 3D model. */
function _trailBodyCountNow() {
return trailBodyCountOutsideModel(
flightState._trailFixes,
trailBodyCount(
flightState._trailFixes,
flightState.now() - RENDER_DELAY_SEC * 1000,
),
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
);
}
@@ -495,25 +667,27 @@ export function createTracking({
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
// Seed every report. The body only draws those at or before the delayed
// display time (now − RENDER_DELAY_SEC); newer ones join it as the display
// passes them. A synthetic forward fix (it carries `reportEpochMs`) is a
// motion aid, never a report. A report drawn at an invented height keeps
// no position and breaks the line.
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
// Floor grounded-history seeds (round 2): a grounded contact's stored
// fixes carry the pre-datum default height — the trail copy sits on
// the surface instead (pure lift; airborne fixes pass through).
flightState._trailPositions.push(
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
);
}
if (Number.isFinite(fix.reportEpochMs)) continue;
// Floor grounded-history seeds (round 2): a grounded contact's stored
// fixes carry the pre-datum default height — the trail copy sits on
// the surface instead (pure lift; airborne fixes pass through).
insertTrailFix(
flightState._trailFixes,
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
historyFixEpochMs(fix),
TRAIL_MAX_POINTS,
{ nullAltitude: fix.inventedHeight === true },
);
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, {
@@ -534,14 +708,13 @@ export function createTracking({
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the
// segment would draw IN FRONT of the plane. Likewise during warm-up the icon
// predates all real history, so there is no valid body point behind it.
// The head starts at the last DISPLAYED body point, the newest fix at or
// before the delayed display time; a fix ahead of the icon would draw the
// segment IN FRONT of the plane. During warm-up the icon predates all real
// history, so there is no valid body point behind it.
if (
!flightState._trackedIcao ||
flightState._trailPositions.length < 2 ||
!flightState._trailFixes.length ||
parts.motion._isTrackWarmingUp()
)
return [];
@@ -549,12 +722,20 @@ export function createTracking({
parts.motion._trackedTrailCached() ||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start =
flightState._trailPositions[
flightState._trailPositions.length - 2
];
// The last body point (a report, or where the icon was drawn a
// moment ago) → the icon. The body is moved on by _trailTick, never
// here: a callback must not add or remove entities.
// No head across a break: a body point more than 150 km away, or
// too long ago or too far to be one path, stays detached; none while
// the aircraft holds at its last point.
const last = trailHeadStartFix(
flightState._trailFixes,
head,
flightState.now(),
flightState._trailBodyCount,
);
if (!last) return [];
const start = last.position;
// Floor the head too (round 2): a grounded tracked contact's DR
// display height is the pre-datum default — without this the last
// segment dives underground while taxiing.
@@ -595,10 +776,10 @@ export function createTracking({
}
/**
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
* Fire-and-forget optional source history backfill (PRD F2). Every trace
* point strictly older than the oldest seeded fix is spliced AHEAD of the
* locally accumulated fine segment, each leg its own segment, capped at
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
* malformed) silently keeps the local-only trail.
* @param {string} icao24 - ICAO hex identifier being tracked.
* @param {number} token - Backfill token captured at request time.
@@ -644,7 +825,13 @@ export function createTracking({
lon = point.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM });
parsed.push({
lat,
lon,
baroAltitudeM: point.baroAltitudeM,
onGround: point.onGround === true,
timeMs: point.observedAtMs,
});
}
if (!parsed.length) return;
@@ -667,39 +854,34 @@ export function createTracking({
)
return;
let older = [];
let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet
for (const { lat, lon, baroAltitudeM } of parsed) {
const baroM =
baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon);
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// Ground/no-alt point with an unresolved floor: hold the previous
// waypoint's altitude (continuity — never a dive to a made-up depth).
// Leading points with nothing to carry keep the old low breadcrumb
// sentinel (an arbitrary placeholder, never a reported altitude).
if (altM == null) altM = lastAltM != null ? lastAltM : 50;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
// Stride-thin the backfill so it plus the live fine segment fit the cap.
const budget = Math.max(
1,
TRAIL_MAX_POINTS - flightState._trailPositions.length,
// Every trace point is kept, each leg its own segment. A 'ground' point
// is at altitude 0, floored at the ground. A point with no altitude is a
// break on both sides: it sits on the ground where the floor is known and
// is skipped otherwise; a skipped point still breaks the line.
const backfill = trailBackfillFixes(
parsed.map(({ lat, lon, baroAltitudeM, onGround, timeMs }) => {
const known = Number.isFinite(baroAltitudeM)
? baroAltitudeM
: onGround
? 0
: null;
const altM = floorAltitudeM(
known == null ? null : known + geoidHeight(lat, lon),
cachedGroundFloor(lat, lon),
);
return {
position:
altM == null ? null : Cesium.Cartesian3.fromDegrees(lon, lat, altM),
timeMs,
nullAltitude: known == null,
};
}),
);
flightState._trailFixes = joinTrailBackfill(
backfill,
flightState._trailFixes,
TRAIL_MAX_POINTS,
);
if (older.length > budget) {
const stride = Math.ceil(older.length / budget);
const thinned = [];
for (let i = 0; i < older.length; i += stride) thinned.push(older[i]);
older = thinned;
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(
flightState._trailPositions.length - TRAIL_MAX_POINTS,
);
}
_refreshTrailDisplay();
}
@@ -709,7 +891,9 @@ export function createTracking({
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
if (flightState._trail) flightState._trail.clear();
if (
flightState._trailHeadEntity &&
@@ -926,15 +1110,9 @@ export function createTracking({
(CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1),
// Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
() =>
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
? AMBER_TRANSPARENT
: TRACKED_ICON_COLOR,
false,
),
// Solid amber (faded while stale) when the billboard is the visual (zoomed out, 3D off,
// or model still loading); transparent once the STANDALONE tracked model is actually up.
color: new Cesium.CallbackProperty(_trackedIconColor, false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
@@ -1157,6 +1335,9 @@ export function createTracking({
_setCockpitContactMode,
_applyCockpitState,
_buildTrackedLabel,
_trackedStaleCue,
_trackedIconColor,
_staleCueTick,
_updateTrackedLabelModel,
_syncTrackedBillboardImage,
_refreshTr3bContact,
@@ -1168,6 +1349,7 @@ export function createTracking({
_syncTracked2dRotation,
_trailFloorPosition,
_appendTrailFix,
_trailTick,
_refreshTrailDisplay,
_startTrail,
_backfillTrail,
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more