Merge branch 'main' into german/vessel-callout-under-card

This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-10 12:47:09 +04:00
commit 8eb0173940
91 files changed
+2823 -219

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,
},
};
}
+7
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:
+7
View File
@@ -43,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.
+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,
+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
+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 },
);
});
+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';
+2 -1
View File
@@ -3619,5 +3619,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}"
}
+2 -1
View File
@@ -3311,5 +3311,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}"
}
+2 -1
View File
@@ -3388,5 +3388,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}"
}
+2 -1
View File
@@ -3388,5 +3388,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}"
}
+2 -1
View File
@@ -3388,5 +3388,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}"
}
+2 -1
View File
@@ -3234,5 +3234,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}"
}
+2 -1
View File
@@ -3388,5 +3388,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}"
}
+2 -1
View File
@@ -3465,5 +3465,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}"
}
+2 -1
View File
@@ -3465,5 +3465,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}"
}
+2 -1
View File
@@ -3234,5 +3234,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}"
}
+2 -1
View File
@@ -3234,5 +3234,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}"
}
+2 -1
View File
@@ -586,7 +586,8 @@
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW",
"SATELLITE"
"SATELLITE",
"Photo © {photographer}"
],
"it": [
"1 min/s",
+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,
+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 =
+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;
+24 -5
View File
@@ -37,6 +37,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,
@@ -865,12 +881,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(),
+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();
});
+5
View File
@@ -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
+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,
};
},
+12 -3
View File
@@ -198,10 +198,16 @@ 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 meta = {
sourceReference: observation.reference,
observedReceiptMs: Date.now(),
callsign: stickyText(callsign, prevMeta?.callsign),
callsign: nextCallsign,
altitude: alt,
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
// untouched aviation `altitude` — never rename/replace it (labels,
@@ -256,8 +262,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
+24
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,25 @@ 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);
});
+9 -2
View File
@@ -230,6 +230,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);
}
}
@@ -300,8 +307,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, {
+4
View File
@@ -342,6 +342,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,
};
}
+1
View File
@@ -1060,6 +1060,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.
+2 -2
View File
@@ -228,8 +228,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 =
+219
View File
@@ -0,0 +1,219 @@
// Street Traffic road lines build one set at a time: rebuilds asked for while
// a set is building wait, and only the newest of them runs.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRendering } from './rendering.js';
// Cesium tests a material's uniforms against these browser classes.
for (const name of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
])
globalThis[name] ??= class {};
// A line primitive's render state is checked against the context's limits,
// which only a WebGL context sets.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
/** A road with flow; `level` picks its bucket (0.1 jam, 0.6 slow, 1 free). */
function road(level = 1) {
return {
flow: { level, closure: false },
segmentDist: [100],
waypoints: Cesium.Cartesian3.fromDegreesArray([
-97.744, 30.267, -97.742, 30.267,
]),
};
}
/**
* Rendering over a scene that records its ground primitives. Each added
* primitive is replaced by a stand-in whose `ready` the test sets, as Cesium
* sets it once the asynchronous geometry is built.
*/
function harness() {
const scene = [];
const added = [];
let linesOn = true;
const state = {
_viewer: {
scene: {
globe: { show: true },
groundPrimitives: {
add(primitive) {
const line = {
ready: false,
roads: primitive.geometryInstances.length,
};
scene.push(line);
added.push(line);
return line;
},
remove(line) {
const index = scene.indexOf(line);
if (index >= 0) scene.splice(index, 1);
},
},
},
},
_liveMode: true,
_showLines: true,
_heatSupported: true,
_heatJamPrim: null,
_heatSlowPrim: null,
_heatFreePrim: null,
_heatRetired: [],
_heatPendingRoads: null,
_heatLineCount: 0,
};
const rendering = createRendering({
state,
services: {},
source: {},
parts: {
style: { heatlineOn: () => linesOn },
animation: { syncRenderDemand() {} },
},
});
return {
state,
rendering,
scene,
added,
setLines(on) {
linesOn = on;
state._showLines = on;
},
/** Finish every build in the scene, then run a frame. */
frame({ built = true } = {}) {
if (built) for (const line of scene) line.ready = true;
rendering.retireHeatLines();
},
};
}
const roads = (count) => Array.from({ length: count }, () => road());
test('rebuilds asked for while lines build do not start more builds', () => {
const { rendering, scene, added, frame } = harness();
rendering.rebuildHeatLines(roads(1));
assert.equal(added.length, 1);
// Progressive paints while the first set builds.
for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count));
frame({ built: false });
assert.equal(added.length, 1, 'no build started during a build');
assert.equal(scene.length, 1);
assert.deepEqual(scene[0].roads, 1);
});
test('the newest rebuild asked for during a build is the one that runs', () => {
const { state, rendering, scene, added, frame } = harness();
rendering.rebuildHeatLines(roads(1));
for (const count of [2, 3, 4]) rendering.rebuildHeatLines(roads(count));
frame();
// One build for the three requests, of the last one's roads.
assert.deepEqual(
added.map((line) => line.roads),
[1, 4],
);
assert.equal(state._heatLineCount, 4);
assert.equal(state._heatPendingRoads, null);
// The set it replaces stays drawn while it builds, then goes.
assert.deepEqual(
scene.map((line) => line.roads),
[1, 4],
);
frame();
assert.deepEqual(
scene.map((line) => line.roads),
[4],
);
// Nothing is waiting, so later frames start nothing.
frame();
assert.equal(added.length, 2);
});
test('a rebuild with nothing building starts at once', () => {
const { rendering, added, frame } = harness();
rendering.rebuildHeatLines(roads(1));
frame();
rendering.rebuildHeatLines(roads(2));
assert.deepEqual(
added.map((line) => line.roads),
[1, 2],
);
});
test('turning lines off during a build removes them at once and drops the waiting rebuild', () => {
const { state, rendering, scene, added, setLines, frame } = harness();
rendering.rebuildHeatLines(roads(1));
frame();
rendering.rebuildHeatLines(roads(2));
rendering.rebuildHeatLines(roads(3));
assert.equal(scene.length, 2, 'the drawn set and the building one');
setLines(false);
rendering.rebuildHeatLines(roads(3));
assert.deepEqual(scene, [], 'not deferred behind the build');
assert.equal(state._heatPendingRoads, null);
assert.equal(state._heatLineCount, 0);
frame();
assert.equal(added.length, 2, 'the waiting rebuild never runs');
assert.deepEqual(scene, []);
});
test('a rebuild right after a build finishes does not keep the lines that build replaced', () => {
const { rendering, scene } = harness();
rendering.rebuildHeatLines(roads(1));
for (const line of scene) line.ready = true;
rendering.rebuildHeatLines(roads(2));
// The second build finishes, and a third is asked for before a frame ran.
for (const line of scene) line.ready = true;
rendering.rebuildHeatLines(roads(3));
assert.deepEqual(
scene.map((line) => line.roads),
[2, 3],
);
});
test('removing the lines drops the waiting rebuild', () => {
const { state, rendering, scene, added, frame } = harness();
rendering.rebuildHeatLines(roads(1));
rendering.rebuildHeatLines(roads(2));
rendering.removeHeatLines();
assert.deepEqual(scene, []);
assert.equal(state._heatPendingRoads, null);
frame();
assert.equal(added.length, 1);
});
test('a stream of rebuilds never leaves more than two sets in the scene, and ends with one', () => {
const { rendering, scene, added, frame } = harness();
let most = 0;
const lines = [road(0.1), road(0.6), road(1)];
// Three primitives per set: one per flow bucket.
for (let paint = 0; paint < 40; paint += 1) {
rendering.rebuildHeatLines(lines);
most = Math.max(most, scene.length);
// A build takes several paints.
if (paint % 5 === 4) frame();
else frame({ built: false });
most = Math.max(most, scene.length);
}
frame();
frame();
assert.equal(most <= 6, true, `${most} primitives at once`);
assert.equal(scene.length, 3, 'only the current set remains');
assert.equal(
scene.every((line) => line.ready),
true,
);
// Without coalescing every paint builds: 40 sets, 120 primitives.
assert.equal(
added.length <= 3 * 10,
true,
`${added.length} primitives built`,
);
});
+39 -13
View File
@@ -59,24 +59,37 @@ export function createRendering({
/** Remove the heat-line / flow-line ground primitives from the scene. */
function removeHeatLines() {
layerState._heatPendingRoads = null;
removePrimitives(takeHeatLines());
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
parts.animation.syncRenderDemand?.();
}
/** @returns {boolean} Whether a line primitive's geometry is still building. */
function linesBuilding() {
return LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready);
}
/**
* Replaced lines stay drawn until their successors finish building
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
* blinks. Called every held frame.
* blinks. Once they have, the replaced lines go and the newest rebuild
* asked for in the meantime starts. Called every held frame.
*/
function retireHeatLines() {
if (!layerState._heatRetired?.length) return;
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
return;
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
if (linesBuilding()) return;
if (layerState._heatRetired?.length) {
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
}
const pending = layerState._heatPendingRoads;
if (pending) {
layerState._heatPendingRoads = null;
rebuildHeatLines(pending);
}
}
/**
@@ -95,24 +108,37 @@ export function createRendering({
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
* sim mode, when both looks are off, or without ground-primitive support.
*
* One build runs at a time. A city loads in progressive paints, each asking
* for a rebuild, and every build drapes up to the cap of polylines
* asynchronously. A rebuild asked for while lines are building only replaces
* the one waiting, which retireHeatLines starts when the build finishes, so
* the scene never holds more than the lines drawn and the lines building.
* Turning the lines off is never deferred.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
const wanted =
layerState._viewer && layerState._liveMode && parts.style.heatlineOn();
if (wanted && linesBuilding()) {
layerState._heatPendingRoads = roads;
return;
}
layerState._heatPendingRoads = null;
// No build is left for replaced lines to wait for (it finished, or lines
// are going off), so they go first and never pile up behind this rebuild.
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
const previous = takeHeatLines();
const finish = () => {
// Nothing new to wait for: the old lines go now.
if (LINE_KEYS.some((key) => layerState[key]))
(layerState._heatRetired ||= []).push(...previous);
// Nothing new to wait for: the old lines go now.
else removePrimitives(previous);
parts.animation.syncRenderDemand?.();
};
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return finish();
if (!wanted) return finish();
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
+4
View File
@@ -200,6 +200,10 @@ export function createState({ services, source }) {
layerState._heatRetired = [];
/** @type {Array|null} Roads of the newest rebuild asked for while the lines were building. */
layerState._heatPendingRoads = null;
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0;
+15 -1
View File
@@ -124,7 +124,14 @@ export function createHeight({ state, services, parts }) {
const cells = new Map();
const collect = (point, answer) => {
if (!onGround) return;
if (!cells.has(answer.cell))
// A cell keeps the last point offered in it: the path is walked
// oldest first, so that is where the vehicle is now or about to be,
// and the mesh sampler reads the surface there.
const held = cells.get(answer.cell);
if (held) {
held.lat = point.lat;
held.lon = point.lon;
} else
cells.set(answer.cell, {
lat: point.lat,
lon: point.lon,
@@ -136,6 +143,13 @@ export function createHeight({ state, services, parts }) {
const newest = entry.fixes.at(-1);
const answer = requestHeight(newest.lat, newest.lon, entry);
entry.heightCell = answer.cell;
// The selected vehicle's path also offers its history, newest segment
// first; the vehicle's own cell is read where its newest fix is.
const own = cells.get(answer.cell);
if (own) {
own.lat = newest.lat;
own.lon = newest.lon;
}
if (answer.height !== null) {
entry.heightM = answer.height;
entry.heightResolved = true;
+108
View File
@@ -2938,6 +2938,114 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget
assert.equal(probed.size, 100);
});
test('mesh refinement probes the latest point the path offers in a cell', async (t) => {
const { createHeight } = await import('./height.js');
// Three points along one cell's stretch of path, oldest first.
const path = [
{ lat: 42.3611, lon: -71.0751 },
{ lat: 42.3612, lon: -71.0753 },
{ lat: 42.3614, lon: -71.0754 },
];
const entry = { key: 'train', to: path[0], fixes: [path.at(-1)] };
const probed = [];
const state = {
_enabled: true,
_selectedKey: null,
_visible: new Set([entry]),
_vehicles: new Map([[entry.key, entry]]),
_floorCursor: 0,
_heightDirty: new Set(),
_viewer: { camera: { positionCartographic: { height: 100 } } },
};
const height = createHeight({
state,
services: {
render: { governorRequestRender() {} },
ground: {
coarseFloorCoord: (lat, lon) => ({
lat: Number(lat.toFixed(3)),
lon: Number(lon.toFixed(3)),
}),
cachedGroundFloor: () => 10,
GROUND_FLOOR_LIFT_M: 1.5,
},
mesh: {
sampleMeshFloorCells(scene, points) {
probed.push(...points.map((p) => [p.lat, p.lon]));
},
},
},
parts: {
trails: {
prepareEntry(e, collect) {
if (collect)
for (const point of path)
collect(point, height.requestHeight(point.lat, point.lon));
},
},
rendering: { requestVisibility() {} },
},
});
t.after(() => height.clear());
height.anchorFloors();
assert.deepEqual(probed, [[42.3614, -71.0754]]);
});
test('the selected vehicle is probed at its newest fix in its own cell', async (t) => {
const { createHeight } = await import('./height.js');
// The selected path offers its active corridor first, then its history
// newest segment first, so the last offer in the cell is an older point.
const newest = { lat: 42.3614, lon: -71.0754 };
const offers = [
newest,
{ lat: 42.3612, lon: -71.0753 },
{ lat: 42.3611, lon: -71.0751 },
];
const entry = { key: 'train', to: offers[0], fixes: [newest] };
const probed = [];
const state = {
_enabled: true,
_selectedKey: entry.key,
_visible: new Set([entry]),
_vehicles: new Map([[entry.key, entry]]),
_floorCursor: 0,
_heightDirty: new Set(),
_viewer: { camera: { positionCartographic: { height: 100 } } },
};
const height = createHeight({
state,
services: {
render: { governorRequestRender() {} },
ground: {
coarseFloorCoord: (lat, lon) => ({
lat: Number(lat.toFixed(3)),
lon: Number(lon.toFixed(3)),
}),
cachedGroundFloor: () => 10,
GROUND_FLOOR_LIFT_M: 1.5,
},
mesh: {
sampleMeshFloorCells(scene, points) {
probed.push(...points.map((p) => [p.lat, p.lon]));
},
},
},
parts: {
trails: {
prepareEntry(e, collect) {
if (collect)
for (const point of offers)
collect(point, height.requestHeight(point.lat, point.lon));
},
},
rendering: { requestVisibility() {} },
},
});
t.after(() => height.clear());
height.anchorFloors();
assert.deepEqual(probed, [[42.3614, -71.0754]]);
});
test('selection churn releases past geometry on every retained vehicle', async (t) => {
const app = harness(t, { floorAt: () => 10 });
const { mergeHistory, seek } = await import('../../data/contactPlayback.js');
+1 -1
View File
@@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') {
for (const workload of WORKLOADS) {
test(`steady moving-source frames stay in budget ${workload.name}`, (t) => {
if (!CALIBRATED_ALLOCATION_RUNTIME) {
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 payload = runAllocationProbe(workload.entries, workload.profile);
+2 -1
View File
@@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () =>
// A full pass run under `mise exec node@24 --` still re-execed system Node 25
// through npm; the forced gate refused ("received 25.6.1") and A3 reported it
// as a product failure. A3 now invokes the binary directly and checks it.
test('only a parseable Node 24 counts as the calibrated allocation runtime', () => {
test('every supported Node major counts as a calibrated allocation runtime', () => {
assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional');
assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true);
assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case');
assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false);
for (const junk of ['', ' ', 'node', undefined, null]) {
+69 -12
View File
@@ -418,13 +418,26 @@ export function createGroundFloor({ terrain, signal }) {
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
// inside the mesh. These cells hold the RENDERED surface height, sampled
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
// at the callers' points, a few per coarse cell near the viewer, each kept
// where it was taken and read back from the nearest (scene.sampleHeight — the same
// source groundSnap uses, which is why 3D models always looked right) and
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
// below the visible surface; slightly above is always fine. The DEM stays
// the instant global prior/fallback and the sanity gate for samples.
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
/** @type {Map<string, Array<{lat: number, lon: number, h: number}>>}
* coarse cell key -> rendered-surface readings (m, ellipsoidal), each kept
* at the point it was probed. A read answers from the reading nearest the
* asked point, so a viaduct and the street beside it in one cell keep their
* own heights. */
const _meshCells = new Map();
/** @constant {number} Readings closer than this (degrees, about 5 m) are
* one spot: the lower one is kept there. */
const MESH_READING_SAME_SPOT_DEG = 0.00005;
/** @constant {number} Readings kept per cell. The sampler probes a cell at
* most eight times, but ground snaps report at each aircraft's position;
* past this many, a reading away from the others is not kept, so a busy
* apron cell stays a short list to scan. */
const MESH_READINGS_PER_CELL = 16;
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
* regime (photoreal stack). On globe stacks the rendered surface IS the
* terrain provider (Re:Earth on keyless), so the DEM is already correct
@@ -444,7 +457,8 @@ export function createGroundFloor({ terrain, signal }) {
/**
* Records a sampled rendered-surface height for the cell containing the
* coordinate (one-shot latch — first accepted sample wins).
* coordinate, unchecked, when the cell has no reading yet. The sampler goes
* through `reportValidatedMeshFloorCell`, which keeps a reading per point.
* @param {number} lat @param {number} lon @param {number} heightM
*/
function reportMeshFloorCell(lat, lon, heightM) {
@@ -456,7 +470,7 @@ export function createGroundFloor({ terrain, signal }) {
return;
const c = coarseFloorCoord(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
if (!_meshCells.has(key)) _meshCells.set(key, [{ lat, lon, h: heightM }]);
}
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
@@ -471,9 +485,11 @@ export function createGroundFloor({ terrain, signal }) {
/**
* Validate and record a rendered-mesh floor through the single shared gate.
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
* rooftop, or aircraft hit would otherwise win the session-long first-write
* latch and poison every floor consumer in the cell.
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD
* or aircraft hit would otherwise poison every floor consumer in the cell.
* The reading is kept at the point it was taken; a second reading at the
* same spot (within about 5 m) keeps the lower of the two. A cell keeps at
* most MESH_READINGS_PER_CELL readings.
* @returns {boolean} Whether the sample passed and was reported.
*/
function reportValidatedMeshFloorCell(lat, lon, heightM) {
@@ -481,13 +497,41 @@ export function createGroundFloor({ terrain, signal }) {
const cell = coarseFloorCoord(lat, lon);
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
reportMeshFloorCell(cell.lat, cell.lon, heightM);
const key = `${cell.lat},${cell.lon}`;
const readings = _meshCells.get(key);
if (!readings) {
_meshCells.set(key, [{ lat, lon, h: heightM }]);
return true;
}
const same = readings.find(
(r) =>
Math.abs(r.lat - lat) < MESH_READING_SAME_SPOT_DEG &&
Math.abs(r.lon - lon) < MESH_READING_SAME_SPOT_DEG,
);
if (same) {
if (heightM < same.h) same.h = heightM;
} else if (readings.length < MESH_READINGS_PER_CELL) {
readings.push({ lat, lon, h: heightM });
}
return true;
}
/**
* Synchronous mesh-cell read — null when the cell is unsampled OR the
* google-3d regime is inactive (globe stacks must floor on the DEM).
* Whether the cell holding the point has the real DEM prior that
* `reportValidatedMeshFloorCell` judges a reading against.
* @param {number} lat @param {number} lon
* @returns {boolean}
*/
function meshFloorPriorReady(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
const c = coarseFloorCoord(lat, lon);
return cachedRealEllipsoidalGround(c.lat, c.lon) != null;
}
/**
* Synchronous mesh-cell read — the cell's reading nearest the point, or
* null when the cell is unsampled OR the google-3d regime is inactive (globe
* stacks must floor on the DEM).
* @param {number} lat @param {number} lon
* @returns {number|null}
*/
@@ -495,8 +539,20 @@ export function createGroundFloor({ terrain, signal }) {
if (!_meshPreferred) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const c = coarseFloorCoord(lat, lon);
const h = _meshCells.get(`${c.lat},${c.lon}`);
return h != null ? h : null;
const readings = _meshCells.get(`${c.lat},${c.lon}`);
if (!readings) return null;
if (readings.length === 1) return readings[0].h;
const k = Math.cos((lat * Math.PI) / 180) ** 2;
let best = readings[0],
bestD = Infinity;
for (const r of readings) {
const d = (r.lat - lat) ** 2 + k * (r.lon - lon) ** 2;
if (d < bestD) {
bestD = d;
best = r;
}
}
return best.h;
}
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
@@ -662,6 +718,7 @@ export function createGroundFloor({ terrain, signal }) {
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
meshFloorPriorReady,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
+1 -1
View File
@@ -101,7 +101,7 @@ const HELD_SNAP_MAX_DRIFT_M = 250;
* alone, and the asymmetry is not tidiness:
*
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
* contradiction. Cells hold a few point readings over ~111 m, the
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
* built to under-read rather than to invent. Releasing on that sign would
+53 -8
View File
@@ -11,6 +11,7 @@ export function createMeshFloorSampler({
coarseFloorCoord,
cachedMeshFloor,
reportValidatedMeshFloorCell,
meshFloorPriorReady,
setMeshFloorPreferred,
meshFloorPreferred,
cachedGroundFloor,
@@ -23,13 +24,16 @@ export function createMeshFloorSampler({
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
// airport apron) — so DEM-floored sprites/trails still buried themselves in
// the mesh while 3D models (which groundSnap against the mesh) looked right.
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
// This module samples the mesh height at the callers' points, a few probes
// per coarse cell, and reports them to groundFloor.js, which keeps each
// reading that passes its gate at its own point; cachedGroundFloor() then
// answers mesh-first, from the reading nearest the asked point.
//
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
// tile-load prioritization at the probe point, so sampling is (a) gated to
// cells NEAR the viewer — where tiles are streamed and where an error is
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
// visible at all, (b) capped per call, (c) at most MAX_PROBES_PER_CELL
// readings per cell for the session, each at a point apart from the others,
// (d) skipped entirely outside the google-3d regime. A returned-undefined
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
//
@@ -49,6 +53,16 @@ export function createMeshFloorSampler({
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @constant {number} Readings per cell: a cell is probed at most this
* many times, each at a point apart from the others, and groundFloor keeps
* each reading at its point (a read answers from the nearest one). */
const MAX_PROBES_PER_CELL = 8;
/** @constant {number} How far apart (degrees, about 17 m) a new probe must
* be from the cell's earlier ones to be worth taking. */
const PROBE_APART_DEG = 0.00015;
/** @type {Map<string, Array<{lat: number, lon: number}>>} Points probed
* per cell that returned a reading. */
const _probedPoints = new Map();
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
@@ -82,7 +96,7 @@ export function createMeshFloorSampler({
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* a few probes per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
@@ -119,8 +133,21 @@ export function createMeshFloorSampler({
const cell = coarseFloorCoord(p.lat, p.lon);
const key = `${cell.lat},${cell.lon}`;
if (attempted.has(key)) continue;
// A cell with a reading is probed again only at a point well apart
// from the ones already probed, a few times at most, so the cell's
// readings follow where its vehicles actually are.
const probes = _probedPoints.get(key);
if (
cachedMeshFloor(cell.lat, cell.lon) != null &&
(probes?.length >= MAX_PROBES_PER_CELL ||
probes?.some(
(q) =>
Math.abs(q.lat - p.lat) < PROBE_APART_DEG &&
Math.abs(q.lon - p.lon) < PROBE_APART_DEG,
))
)
continue;
attempted.add(key);
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
@@ -131,9 +158,11 @@ export function createMeshFloorSampler({
}
let height;
try {
// Probe where the caller's point is, not at the cell's centre: a
// centre that falls on a roof would lift everything in the cell.
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
p.lon,
p.lat,
0,
_scratchProbe,
);
@@ -171,7 +200,23 @@ export function createMeshFloorSampler({
// feasible and deliberately deferred — see the P2 in
// the project roadmap, which bundles it with the fly_route probe so
// one post-launch pass settles rendered-mesh sampling everywhere.
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
const accepted = reportValidatedMeshFloorCell(p.lat, p.lon, height);
// A spot counts as probed once the gate has judged it against a real
// prior: accepted, or refused with the cell's DEM warm. A spot refused
// only because the DEM was still cold is tried again later. Each spot is
// recorded once, so a cell retried while it has no reading does not fill
// its probe allowance with the same spot.
if (!accepted && !meshFloorPriorReady(cell.lat, cell.lon)) continue;
if (!probes) _probedPoints.set(key, [{ lat: p.lat, lon: p.lon }]);
else if (
probes.length < MAX_PROBES_PER_CELL &&
!probes.some(
(q) =>
Math.abs(q.lat - p.lat) < PROBE_APART_DEG &&
Math.abs(q.lon - p.lon) < PROBE_APART_DEG,
)
)
probes.push({ lat: p.lat, lon: p.lon });
}
}
+74
View File
@@ -412,3 +412,77 @@ test('squawks are validated and omitted OpenSky status differs from an explicit
assert.equal(quiet.squawk, null);
assert.equal(quiet.emergency, null);
});
test('a photo enrichment asks Planespotters directly and returns the checked photo', async () => {
const requests = [];
const source = createFlightSource({
fetchImpl: async (url, init) => {
requests.push({ url, init });
return response({
photos: [
{
thumbnail_large: {
src: 'https://t.plnspttrs.net/33043/1745410_822dfe330c_280.jpg',
size: { width: 420, height: 280 },
},
link: 'https://www.planespotters.net/photo/1745410/x?utm_source=api',
photographer: 'Jan Seler',
},
],
});
},
});
const signal = new AbortController().signal;
const photo = await source.getEnrichment(
{ kind: 'photo', id: 'a0f1bb' },
{ signal },
);
assert.equal(
requests[0].url,
'https://api.planespotters.net/pub/photos/hex/a0f1bb',
);
assert.ok(requests[0].init.signal, 'the request is cancellable');
assert.equal(requests[0].init.credentials, 'omit');
assert.deepEqual(photo, {
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',
});
});
test('a photo enrichment reads an empty answer as no photo and refuses a bad hex', async () => {
let calls = 0;
const source = createFlightSource({
fetchImpl: async (_url, _init) => {
calls++;
return response({ photos: [] });
},
});
assert.deepEqual(
await source.getEnrichment({ kind: 'photo', id: 'a0f1bb' }),
{ found: false },
);
await assert.rejects(source.getEnrichment({ kind: 'photo', id: '../x' }), {
code: 'unsupported',
});
assert.equal(calls, 1);
});
test('a photo enrichment outage rejects without echoing the body', async () => {
for (const status of [403, 429, 502]) {
const source = createFlightSource({
fetchImpl: async () => response({ error: 'sensitive' }, {}, status),
});
await assert.rejects(
source.getEnrichment({ kind: 'photo', id: 'a0f1bb' }),
(error) => {
assert.equal(error.status, status);
assert.equal(error.message.includes('sensitive'), false);
return true;
},
);
}
});
+29
View File
@@ -11,10 +11,37 @@ import {
readsbIdentities,
} from './aircraft.js';
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
/** Planespotters' public photo lookup, called straight from the browser (it
* answers with `access-control-allow-origin: *`). */
const PLANESPOTTERS_PHOTOS = 'https://api.planespotters.net/pub/photos/hex/';
/** A photo is decoration: a slow answer gives up rather than hold an
* enrichment slot. */
const PHOTO_TIMEOUT_MS = 8000;
/** The tracked plane's Planespotters photo, read and host-checked. */
async function planespottersPhoto(fetchImpl, hex, signal) {
const id = String(hex || '').toLowerCase();
if (!/^[0-9a-f]{6}$/.test(id))
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
const timeout = AbortSignal.timeout(PHOTO_TIMEOUT_MS);
const { response, payload } = await readResponse(
fetchImpl,
PLANESPOTTERS_PHOTOS + id,
{
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
credentials: 'omit',
},
'Planespotters',
);
if (!response.ok) throw httpError(response, 'Planespotters');
return parsePlanespottersPhoto(payload);
}
function openSkyError(response) {
const error = httpError(response, 'Flights');
const mode = String(
@@ -106,6 +133,8 @@ export function createFlightSource({
};
},
async getEnrichment(query, { signal } = {}) {
if (query.kind === 'photo')
return planespottersPhoto(fetchImpl, query.id, signal);
if (!['type', 'route'].includes(query.kind))
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
const { response, payload } = await readResponse(
+2 -2
View File
@@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters
const first = json(await request());
assert.equal(first.count, 130001);
assert.equal(first.sources.filter((s) => s.ok).length, 1);
assert.equal(calls, 4);
assert.equal(calls, 3);
assert.equal(json(await request()).count, 130001);
assert.equal(calls, 4);
assert.equal(calls, 3);
assert.deepEqual(json(await request('/status')).transactions, {
used: 3,
limit: 5000,
+32 -9
View File
@@ -20,12 +20,9 @@ import {
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import {
GEV_REALTIME_TOOLS,
offeredTools,
realtimeTools,
} from '../../server/providers/openai/tools.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createDebugLogHandler } from '../../server/providers/openai/debug-log.js';
import { realtimeSessionTools } from '../../server/providers/openai/realtime.js';
import { attachVoiceResult } from '../voice/speech.js';
import { sanitizeDebugValue } from '../voice/realtimeDiagnostics.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
@@ -199,7 +196,10 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
sent.at(-1).session.instructions,
realtimeInstructions(guidance),
);
assert.deepEqual(sent.at(-1).session.tools, realtimeTools());
assert.deepEqual(
sent.at(-1).session.tools,
realtimeSessionTools(GEV_REALTIME_TOOLS),
);
}
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
@@ -607,7 +607,10 @@ test('osm_query is offered only when an operator configured Overpass', async (t)
assert.doesNotMatch(plain.instructions, /osm_query/);
assert.ok(names(plain).includes('resolve_area'));
assert.ok(names(plain).includes('find_imagery'));
assert.deepEqual(configured.tools, GEV_REALTIME_TOOLS);
assert.deepEqual(
configured.tools,
realtimeSessionTools(GEV_REALTIME_TOOLS, { overpass: true }),
);
assert.match(configured.instructions, /osm_query/);
assert.equal(
configured.instructions,
@@ -656,7 +659,7 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the
);
assert.equal(response.status, 200);
// Without an operator Overpass, osm_query is left out of the session.
assert.deepEqual(sent.at(-1).session.tools, offeredTools(tools));
assert.deepEqual(sent.at(-1).session.tools, realtimeSessionTools(tools));
assert.ok(
!sent.at(-1).session.tools.some(({ name }) => name === 'osm_query'),
);
@@ -740,6 +743,26 @@ test('key setup writes only the supplied application root, retains request guard
assert.equal(existsSync(path.join(untouched, '.env')), false);
});
test('Realtime sessions leave out the Unicode property patterns OpenAI refuses (#1618)', async (t) => {
// OpenAI answers 400 invalid_function_parameters for a whole Realtime session
// whose tool schema uses `\p{…}`, as resolve_area's area ids do, so voice
// never connects. The session drops only those patterns, as typed chat does.
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
sent.push(JSON.parse(options.body).session);
return Response.json({ value: 'fixture-ephemeral' });
});
const response = await request(
install(openAiRealtimeProxy({})).get('/api/realtime/token'),
);
assert.equal(response.status, 200);
const { tools } = sent.at(-1);
assert.ok(tools.some(({ name }) => name === 'resolve_area'));
assert.doesNotMatch(JSON.stringify(tools), /\\\\[pP]\{/);
});
test('Realtime service configuration selects compatible endpoint/model without forwarding request model IDs or keys', async () => {
const handler = install(
openAiRealtimeProxy({
@@ -757,7 +780,7 @@ test('Realtime service configuration selects compatible endpoint/model without f
assert.equal(payload.session.model, 'configured-model');
assert.deepEqual(
payload.session.tools,
realtimeTools({ overpass: false }),
realtimeSessionTools(GEV_REALTIME_TOOLS),
);
return Response.json({ value: 'short-lived-fixture' });
},
+70
View File
@@ -245,3 +245,73 @@ test('a repaint that leaves focus outside the panel does not pull it back', asyn
'an empty cohort renders no list',
);
});
const photo = (extra = {}) => ({
src: 'https://t.plnspttrs.net/1/abc123_280.jpg',
width: 280,
height: 186,
link: 'https://www.planespotters.net/photo/1/n123ab',
credit: 'Photo © Jane Doe',
...extra,
});
test('a tracked flight’s photo shows under its name, credited and linked to its page', async (t) => {
const { panel, publish } = fixture(t);
await publish({ ...results(), photo: photo() });
const root = panel();
const link = root.querySelector('a.military-awareness-photo');
assert.ok(link, 'the photo renders');
assert.equal(
root.querySelector('.military-awareness-subject').nextElementSibling,
link,
'directly under the subject line',
);
assert.equal(link.getAttribute('href'), photo().link);
assert.equal(link.getAttribute('target'), '_blank');
assert.equal(link.getAttribute('rel'), 'noopener');
const img = link.querySelector('img');
assert.equal(img.getAttribute('src'), photo().src);
assert.equal(img.getAttribute('alt'), '');
assert.equal(img.getAttribute('width'), '280');
assert.equal(img.getAttribute('height'), '186');
assert.equal(link.querySelector('small').textContent, 'Photo © Jane Doe');
});
test('no photo renders without one', async (t) => {
const { panel, publish } = fixture(t);
await publish({ ...results(), photo: null });
assert.equal(panel().querySelector('.military-awareness-photo'), null);
});
test('a photo whose image fails to load is hidden until another arrives', async (t) => {
const { panel, publish } = fixture(t);
await publish({ ...results(), photo: photo() });
panel()
.querySelector('.military-awareness-photo img')
.dispatchEvent(new Event('error'));
await nextTick();
assert.equal(panel().querySelector('.military-awareness-photo'), null);
// A repaint with the same photo keeps it hidden.
await publish({ ...results({ previousDisabled: false }), photo: photo() });
assert.equal(panel().querySelector('.military-awareness-photo'), null);
// Another plane's photo shows.
const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' });
await publish({ ...results(), photo: next });
assert.equal(
panel().querySelector('.military-awareness-photo img').getAttribute('src'),
next.src,
);
});
test('a late error from a previous photo’s image does not hide the current one', async (t) => {
const { panel, publish } = fixture(t);
const previous = photo();
const current = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' });
await publish({ ...results(), photo: current });
// The error belongs to the image that failed: here, one still on the old src.
const img = panel().querySelector('.military-awareness-photo img');
img.setAttribute('src', previous.src);
img.dispatchEvent(new Event('error'));
await nextTick();
assert.ok(panel().querySelector('.military-awareness-photo'));
});
+25
View File
@@ -360,6 +360,31 @@ html:not([lang='en']) .radio-transport button {
margin: 0 0 7px;
color: #fff;
}
.military-awareness-photo {
display: block;
margin: 0 0 8px;
color: #a9b7c7;
text-decoration: none;
}
.military-awareness-photo img {
display: block;
width: 100%;
height: auto;
border: 1px solid rgba(98, 181, 255, 0.34);
border-radius: 4px;
}
.military-awareness-photo small {
display: block;
margin-top: 3px;
}
.military-awareness-photo:hover small,
.military-awareness-photo:focus-visible small {
color: #fff;
}
.military-awareness-photo:focus-visible {
outline: 1px solid rgba(127, 200, 255, 0.85);
outline-offset: 2px;
}
.military-awareness-controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
+11
View File
@@ -11,6 +11,13 @@ import AwarenessNames from './AwarenessNames.vue';
const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore());
const user = { origin: 'user' };
const root = ref(null);
// A photo whose image failed to load stays hidden until another one arrives.
// The failing image's own src is recorded, so a late error from a previous
// photo never hides the current one.
const failedPhoto = ref('');
const fail = (event) => {
failedPhoto.value = event.target.getAttribute('src');
};
// A live repaint keeps focus on the same control by identity: browsers drop
// focus from a node Vue moves, removes or disables, so the control is found
@@ -52,6 +59,10 @@ onUpdated(() => {
</div>
<template v-else-if="view.kind === 'results'">
<div class="military-awareness-subject">{{ view.subject }}</div>
<a v-if="view.photo && view.photo.src !== failedPhoto" class="military-awareness-photo" :href="view.photo.link" target="_blank" rel="noopener">
<img :src="view.photo.src" :width="view.photo.width ?? undefined" :height="view.photo.height ?? undefined" alt="" @error="fail" />
<small>{{ view.photo.credit }}</small>
</a>
<AwarenessNames v-if="view.subjectNames" :names="view.subjectNames" />
<div class="military-awareness-controls" role="group" :aria-label="view.navigation.label">
<button type="button" data-awareness-action="previous" :title="view.navigation.previous.title" :disabled="view.navigation.previous.disabled" @click="layer?.navigatePrevious(user)">{{ view.navigation.previous.text }}</button>
+10 -13
View File
@@ -7,7 +7,7 @@ import {
ALLOCATION_TEST_FILES,
UNIT_TEST_ROOTS,
allocationTestArgs,
assertNode24AllocationRuntime,
assertCalibratedAllocationRuntime,
buildUnitTestPlan,
discoverUnitTestFiles,
isCalibratedAllocationRuntime,
@@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => {
}
});
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
test('allocation runtime calibration covers both supported Node majors', () => {
assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0');
assert.throws(
() => assertNode24AllocationRuntime('22.23.1'),
/calibrated Node 24 runtime/,
);
assert.throws(
() => assertNode24AllocationRuntime('26.0.0'),
/calibrated Node 24 runtime/,
() => assertCalibratedAllocationRuntime('22.23.1'),
/calibrated Node 24 or 26/,
);
assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0');
assert.equal(isCalibratedAllocationRuntime('24.19.0'), true);
assert.equal(isCalibratedAllocationRuntime('22.23.1'), false);
assert.equal(isCalibratedAllocationRuntime('26.3.0'), false);
assert.equal(isCalibratedAllocationRuntime('26.3.0'), true);
});
test('npm test stays green on every supported engine, not only the calibrated one', () => {
test('npm test calibrates both supported engines while retaining off-range warnings', () => {
// package.json wiring: `npm test` must invoke this runner, and the engines
// range it advertises must not be narrower than what the runner tolerates.
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs');
const enginesNode = String(pkg.engines?.node || '');
assert.ok(enginesNode, 'engines.node must be declared');
// The runner throws for uncalibrated runtimes ONLY behind the explicit
// opt-in env; by default it skips, so a supported non-24 engine cannot fail.
// Unsupported runtimes only skip probes without the explicit opt-in hard gate.
// Node 26 is calibrated and runs those probes with the same budgets.
const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8');
assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/);
assert.match(runner, /SKIPPED .*allocation microbenchmarks/);