mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge branch 'main' into feat/hebrew-i18n
This commit is contained in:
85 files changed
+3208
-126
No files matched your search
+18
-1
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1802,3 +1802,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
@@ -1518,3 +1518,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => {
|
||||
const record = normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: {
|
||||
'surveillance:type': 'ALPR',
|
||||
operator: ' Şehir\n\u0000 PD ',
|
||||
manufacturer: '😀'.repeat(200),
|
||||
'camera:type': null,
|
||||
'surveillance:zone': '\t traffic zone ',
|
||||
},
|
||||
});
|
||||
assert.equal(record.operator, 'Şehir PD');
|
||||
assert.equal(record.manufacturer.length, 180);
|
||||
assert.equal(record.manufacturer, '😀'.repeat(90));
|
||||
assert.equal(record.cameraType, null);
|
||||
assert.equal(record.zone, 'traffic zone');
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+24
-3
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One-line text capped at `maxLength` without splitting a surrogate pair. Still untrusted. */
|
||||
export function normalizeDisplayText(value, maxLength = 180) {
|
||||
if (value == null || typeof value === 'object') return '';
|
||||
return String(value)
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
.replace(/[\uD800-\uDBFF]$/, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -22,6 +22,7 @@ export const {
|
||||
reportMeshFloorCell,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportValidatedMeshFloorCell,
|
||||
meshFloorPriorReady,
|
||||
cachedMeshFloor,
|
||||
_clearMeshFloorCellsForTest,
|
||||
cachedGroundFloor,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -586,7 +586,8 @@
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"Photo © {photographer}"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { normalizeDisplayText } from '../../data/displayText.js';
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
const t = normalizeDisplayText(value);
|
||||
return t || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
VESSEL_LIFT_M,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
export function createQueries({
|
||||
vesselState,
|
||||
@@ -575,7 +576,7 @@ export function createQueries({
|
||||
* @param {Object} [options={}] - Options from the detection system.
|
||||
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
|
||||
* @param {number} [options.seed] - Seed offset for stride sampling.
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
@@ -610,6 +611,9 @@ export function createQueries({
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
// Detection hides the callout while this card paints.
|
||||
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
|
||||
cardEntryId: components.cards.vesselOverlayEntryId(record),
|
||||
klass: record.type
|
||||
? String(record.type).toUpperCase().slice(0, 14)
|
||||
: undefined,
|
||||
|
||||
@@ -212,6 +212,10 @@ const _paintQueue = [];
|
||||
const _paintItemPool = [];
|
||||
const _paintRectPool = [];
|
||||
const _paintRectByKey = new Map();
|
||||
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
|
||||
const _queuedItemBySource = new Map();
|
||||
/** Index rows that stop painting are dropped this many frames apart. */
|
||||
const QUEUED_ITEM_PRUNE_FRAMES = 600;
|
||||
const _hitRects = [];
|
||||
const _paintedBySource = Object.create(null);
|
||||
const _paintedSourceKeys = [];
|
||||
@@ -1042,6 +1046,32 @@ export function getOverlayPaintRect(sourceId, entryId) {
|
||||
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity an entry paints with this frame, or 0 when it is not queued or the
|
||||
* yield-collision pass hid it. Custom lanes may call it before later lanes
|
||||
* publish paint rects. Leaves out the Cyber sonar fade.
|
||||
* @param {string} sourceId
|
||||
* @param {string} entryId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getQueuedOverlayEntryAlpha(sourceId, entryId) {
|
||||
if (_destroyed) return 0;
|
||||
const item = _queuedItemBySource.get(sourceId)?.get(entryId);
|
||||
// Pooled items are reassigned every frame: check the frame and the entry.
|
||||
if (!item || item.stamp !== _frameStamp) return 0;
|
||||
// Declutter has already run when custom lanes call this.
|
||||
if (item.collisionHidden) return 0;
|
||||
const record = item.record;
|
||||
if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0;
|
||||
return (
|
||||
record.sourceAlpha *
|
||||
item.temporalAlpha *
|
||||
record.distanceAlpha *
|
||||
record.altitudeAlpha *
|
||||
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
|
||||
* @param {number} x
|
||||
@@ -2223,7 +2253,34 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
item.stamp = _frameStamp;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
// Keyed by source and id, not `record.key`, so a lookup builds no string.
|
||||
// Also holds cards fading out after they left `_records`.
|
||||
let bySource = _queuedItemBySource.get(record.entry.source);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
_queuedItemBySource.set(record.entry.source, bySource);
|
||||
}
|
||||
bySource.set(record.entry.id, item);
|
||||
}
|
||||
|
||||
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
|
||||
function pruneQueuedItemIndex() {
|
||||
for (const [sourceId, bySource] of _queuedItemBySource) {
|
||||
for (const [entryId, item] of bySource) {
|
||||
// A pooled item that now paints another entry keeps a fresh stamp.
|
||||
const entry = item.record.entry;
|
||||
if (
|
||||
item.stamp !== _frameStamp ||
|
||||
entry.id !== entryId ||
|
||||
entry.source !== sourceId
|
||||
) {
|
||||
bySource.delete(entryId);
|
||||
}
|
||||
}
|
||||
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCanPaint(sourceId) {
|
||||
@@ -2295,6 +2352,7 @@ function solveDomains(timestamp) {
|
||||
solveRevision += domain.arbiter.solveRevision;
|
||||
}
|
||||
_paintQueue.length = _paintCount;
|
||||
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
|
||||
_solveDirty = false;
|
||||
_diagnostics.solveMs = solveMs;
|
||||
_diagnostics.selectedCount = selectedCount;
|
||||
@@ -2423,24 +2481,26 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
|
||||
function entryKeyholeAlpha(entry, placement, keyhole) {
|
||||
if (entry.edgeFade !== 'keyhole') return 1;
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
return keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
if (entry.edgeFade === 'keyhole') {
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
keyholeAlpha =
|
||||
keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
|
||||
// All five channels are normalized at their source. Keep the multiply on
|
||||
// the hot paint path so its intermediate doubles remain unboxed; the pure
|
||||
// `combinedOverlayAlpha` export still specifies/tests the same binding.
|
||||
@@ -2829,6 +2889,7 @@ export function destroyWorldOverlay() {
|
||||
_accessibleActivatorByKey.clear();
|
||||
_accessibilitySignature = '';
|
||||
_paintRectByKey.clear();
|
||||
_queuedItemBySource.clear();
|
||||
_paintCount = 0;
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
activateWorldOverlayAction,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
getQueuedOverlayEntryAlpha,
|
||||
isOverlayPointVisible,
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
@@ -1222,6 +1223,79 @@ test('custom detection lane receives the shared host frame and paints below ordi
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
|
||||
// The detection lane paints before the cards publish this frame's paint
|
||||
// rects, so it reads the queued alpha instead.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
|
||||
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
|
||||
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
|
||||
rect: getOverlayPaintRect('cards', 'SHOWN'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
setOverlayEntries('cards', [selectedEntry('SHOWN', {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
|
||||
position: positionAtScreen(100, 100),
|
||||
})]);
|
||||
setOverlaySourceVisible('hidden-cards', false);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
|
||||
'the card is queued while its paint rect is still unpublished');
|
||||
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
|
||||
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
|
||||
|
||||
clearOverlaySource('cards');
|
||||
env.postRender.raise();
|
||||
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
|
||||
destroyWorldOverlay();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane reads zero for a yielding entry the declutter pass hid this frame', () => {
|
||||
// Declutter runs before custom lanes, so a yielding entry it hid must not
|
||||
// report its full queued alpha to them.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
winner: getQueuedOverlayEntryAlpha('cards', 'ABOVE'),
|
||||
yielder: getQueuedOverlayEntryAlpha('labels', 'YIELDER'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
// Two cards take both vertical slots, so a vertical-only yielder has none.
|
||||
const at = positionAtScreen(200, 150);
|
||||
const card = (id, placement) => selectedEntry(id, {
|
||||
position: at,
|
||||
placement,
|
||||
verticalOnly: true,
|
||||
collisionGroup: 'shared-card',
|
||||
});
|
||||
setOverlayEntries('cards', [card('ABOVE', 'above'), card('BELOW', 'below')]);
|
||||
setOverlayEntries('labels', [selectedEntry('YIELDER', {
|
||||
position: at,
|
||||
verticalOnly: true,
|
||||
collisionGroup: 'yielding-label',
|
||||
yieldToCollisionGroup: 'shared-card',
|
||||
})]);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.ok(getOverlayPaintRect('cards', 'ABOVE'), 'the winner painted');
|
||||
assert.equal(getOverlayPaintRect('labels', 'YIELDER'), null, 'the yielder was hidden');
|
||||
assert.ok(seen[0].winner > 0, 'the winner reads its queued alpha');
|
||||
assert.equal(seen[0].yielder, 0, 'the yielder hidden this frame reads zero');
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('custom-lane painter state is bracketed by host save/restore', () => {
|
||||
// A painter that leaks a clip or alpha must not corrupt later frames: the
|
||||
// host wraps every custom-lane painter in save/try/finally-restore. Pin the
|
||||
@@ -2489,3 +2563,49 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
|
||||
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test('the queued-card index stays bounded while card identities churn', () => {
|
||||
// The index is internal: capture it through Map.prototype.set, like the
|
||||
// projection-record test.
|
||||
const originalSet = Map.prototype.set;
|
||||
let queuedIndex = null;
|
||||
Map.prototype.set = function captureQueuedIndex(key, value) {
|
||||
if (typeof key === 'string' && key.startsWith('churn-card-')
|
||||
&& value && typeof value === 'object' && 'temporalAlpha' in value
|
||||
&& 'placement' in value && 'stamp' in value) {
|
||||
queuedIndex = this;
|
||||
}
|
||||
return originalSet.call(this, key, value);
|
||||
};
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
const FRAMES = 1300;
|
||||
for (let frame = 0; frame < FRAMES; frame++) {
|
||||
// One card whose id changes every frame reuses one pooled paint item,
|
||||
// like a vessel cohort republished as the camera moves.
|
||||
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
|
||||
'the live card is queued in the frame just painted');
|
||||
if (frame > 0) {
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
|
||||
'a stale row never reports the previous card as queued');
|
||||
}
|
||||
}
|
||||
assert.ok(queuedIndex, 'the queued-card index was never populated');
|
||||
// Rows are pruned every 600 painted frames, so with one new id per frame
|
||||
// the index holds at most one prune window of ids plus the live card.
|
||||
const bound = 600 + 1;
|
||||
assert.ok(
|
||||
queuedIndex.size <= bound,
|
||||
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
|
||||
);
|
||||
} finally {
|
||||
Map.prototype.set = originalSet;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) {
|
||||
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
|
||||
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
|
||||
tier: index % 17 === 0 ? 'military' : undefined,
|
||||
// Vessels name their ambient card, so each callout asks the host for it.
|
||||
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
|
||||
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
+69
-12
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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,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>
|
||||
|
||||
+25
-11
@@ -1,4 +1,5 @@
|
||||
import { interruptGlobeRotation } from '../cameraVerbs.js';
|
||||
import { normalizeDisplayText } from '../data/displayText.js';
|
||||
import {
|
||||
layerSnapshot,
|
||||
feedProvenanceEnvelope,
|
||||
@@ -5068,13 +5069,7 @@ function uniqueStrings(values) {
|
||||
* @returns {string} Sanitized single-line label (max 120 chars).
|
||||
*/
|
||||
function sanitizeLabel(value) {
|
||||
const text = String(value || '');
|
||||
let out = '';
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0);
|
||||
out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines
|
||||
}
|
||||
return out.replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
return normalizeDisplayText(value, 120);
|
||||
}
|
||||
|
||||
function cachePrecision(service) {
|
||||
@@ -5191,10 +5186,8 @@ function compactProperties(props) {
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
if (value == null || typeof value === 'object') return '';
|
||||
const text = String(value).trim();
|
||||
if (!text || text === 'undefined' || text === 'null') return '';
|
||||
return text.length > 180 ? `${text.slice(0, 177)}...` : text;
|
||||
const text = normalizeDisplayText(value);
|
||||
return text === 'undefined' || text === 'null' ? '' : text;
|
||||
}
|
||||
|
||||
function layerTitle(layerId) {
|
||||
@@ -5359,6 +5352,22 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
||||
'noradId',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys that identify an analyst record. The referent registry, track_entity,
|
||||
* locateLayerEntity and follow-up filters match on them, so they skip
|
||||
* display-text normalization.
|
||||
*/
|
||||
const ANALYST_CANONICAL_FIELDS = new Set([
|
||||
'layerKey',
|
||||
'id',
|
||||
'icao24',
|
||||
'mmsi',
|
||||
'noradId',
|
||||
'registration',
|
||||
'callsign',
|
||||
'routeId',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compact one analyst record for the voice payload: identity, position (so a
|
||||
* follow-up can fly to it), distance, and that layer's manifest fields with
|
||||
@@ -5396,6 +5405,11 @@ function compactAnalystItem(r) {
|
||||
} else compact[key] = r[key];
|
||||
}
|
||||
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
||||
// Canonical keys stay raw: bikeshare's id is the raw station name.
|
||||
for (const key of Object.keys(compact)) {
|
||||
if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key))
|
||||
compact[key] = normalizeDisplayText(compact[key]);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
|
||||
@@ -6548,6 +6548,84 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
|
||||
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
|
||||
});
|
||||
|
||||
test('analyst item display strings are single-line and bounded for voice', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }];
|
||||
const { runner } = manifestRunner({ bikeshare: { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => rows } });
|
||||
const result = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 1 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.items[0].name.length, 180);
|
||||
assert.equal(result.items[0].label, 'Town Station');
|
||||
assert.equal(result.items[0].id, 'stable-id');
|
||||
for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/);
|
||||
});
|
||||
|
||||
test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const referents = createReferentRegistry();
|
||||
const longName = 'Station ' + 'é'.repeat(200);
|
||||
const spaced = 'Congress Ave & 2nd';
|
||||
const stations = [
|
||||
{ id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 },
|
||||
{ id: longName, name: longName, lat: 30.2711, lon: -97.7437 },
|
||||
];
|
||||
const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations };
|
||||
const runner = createActionRunner({
|
||||
viewer, styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([['bikeshare', { module: bikeshare }]]),
|
||||
isEnabled: () => true,
|
||||
getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }],
|
||||
},
|
||||
deixis: { referents },
|
||||
});
|
||||
const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 });
|
||||
assert.equal(listed.ok, true);
|
||||
const bySpaced = listed.items.find((item) => item.id === spaced);
|
||||
const byLong = listed.items.find((item) => item.id === longName);
|
||||
assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs');
|
||||
assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized');
|
||||
assert.equal(byLong.name.length, 180, 'display name is still bounded');
|
||||
assert.equal(byLong.id.length, longName.length);
|
||||
const registered = referents.list();
|
||||
assert.equal(registered.length, 2, 'the analyst result became the numbered list');
|
||||
for (const ref of registered) {
|
||||
const station = stations.find((row) => row.id === ref.id);
|
||||
assert.ok(station, `referent ${ref.n} keeps the canonical ID`);
|
||||
const flown = await runner('fly_to_location', { referent: ref.n });
|
||||
assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`);
|
||||
assert.equal(flown.latitude, station.lat);
|
||||
assert.equal(flown.longitude, station.lon);
|
||||
}
|
||||
});
|
||||
|
||||
test('analyst_query keeps transit route IDs raw so a follow-up filter still matches', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
for (const routeId of ['Route 17', 'R'.repeat(181)]) {
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const vehicles = [{ id: 'bus-1', routeId, lat: 30, lon: -97 }];
|
||||
const transit = { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => vehicles };
|
||||
const runner = createActionRunner({
|
||||
viewer, styleManager,
|
||||
dataManager: {
|
||||
layers: new Map([['transit', { module: transit }]]),
|
||||
isEnabled: () => true,
|
||||
getAll: () => [{ id: 'transit', name: 'transit', enabled: true, stats: transit.getStats() }],
|
||||
},
|
||||
});
|
||||
const listed = await runner('analyst_query', { layers: ['transit'], scope: { kind: 'anywhere' }, limit: 1 });
|
||||
assert.equal(listed.ok, true);
|
||||
assert.equal(listed.items[0].routeId, routeId);
|
||||
const followUp = await runner('analyst_query', {
|
||||
followUp: true,
|
||||
filters: [{ field: 'routeId', op: 'eq', value: listed.items[0].routeId }],
|
||||
});
|
||||
assert.equal(followUp.ok, true);
|
||||
assert.equal(followUp.count, 1, 'the returned route ID matches the same vehicle');
|
||||
}
|
||||
});
|
||||
|
||||
test('set_panel_open accepts the Authoring name for the scene panel', async () => {
|
||||
const calls = [];
|
||||
const runner = createGevActionRunner({
|
||||
|
||||
Reference in new issue
Block a user