mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
chore: merge main into viewport diagnostics pr (#835)
This commit is contained in:
commit
26b268a06c
53 files changed
+1787
-46
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
+258
-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,9 +14,14 @@ 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,
|
||||
@@ -31,6 +36,31 @@ import {
|
||||
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(
|
||||
@@ -404,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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user