mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 05:12:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a904562f0f | ||
|
|
1a8defbfc8 |
No files matched your search
+4
-2
@@ -30,6 +30,7 @@ How to read this:
|
|||||||
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
||||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||||
|
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo, linked to its Planespotters page; "Planespotters.net" in Data attribution |
|
||||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||||
@@ -200,7 +201,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
contributors, ODbL 1.0; GPSJAM for aggregation only.
|
contributors, ODbL 1.0; GPSJAM for aggregation only.
|
||||||
|
|
||||||
|
|
||||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||||
|
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, whose page is an `https://www.planespotters.net` URL, and which names its photographer is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name is visible text next to it ("Photo © {photographer}"), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
|
||||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||||
@@ -538,4 +540,4 @@ NOAA density imagery with the raw GLM product.
|
|||||||
|
|
||||||
## In-app attribution
|
## In-app attribution
|
||||||
|
|
||||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Show a photo of the tracked aircraft. Tracking a civil flight asks
|
||||||
|
Planespotters for that aircraft's photo, and the CONTEXT panel's CONTACTS view
|
||||||
|
shows it with the photographer's credit and a link to the photo's page. A
|
||||||
|
flight lookup (type, route or photo) that failed temporarily is now asked
|
||||||
|
again after a minute instead of staying empty for the session, and a callsign
|
||||||
|
change drops the previous callsign's route (Sameh Khamis, @samehkhamis, #1647).
|
||||||
@@ -136,6 +136,9 @@ socket/watchdog on server close and re-reads configuration after restart.
|
|||||||
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
The portable `gods-eye-view/sources/adsb-lol` export normalizes existing aircraft
|
||||||
records without importing Node middleware or a renderer. Browser layer/controller
|
records without importing Node middleware or a renderer. Browser layer/controller
|
||||||
separation is outside this server extraction.
|
separation is outside this server extraction.
|
||||||
|
The portable `gods-eye-view/sources/planespotters` export reads a Planespotters
|
||||||
|
photo answer (upstream or a server's cached shape) into one host-checked,
|
||||||
|
credited thumbnail; it has no fetch, DOM or Node dependency.
|
||||||
|
|
||||||
## Place-search and routing providers
|
## Place-search and routing providers
|
||||||
|
|
||||||
|
|||||||
@@ -2812,6 +2812,17 @@ in `ais-store.js`. Common response caps, request coalescing and query parsing
|
|||||||
have their own modules. `server/providers/local.js` composes these with the
|
have their own modules. `server/providers/local.js` composes these with the
|
||||||
remaining providers and retains existing named compatibility exports.
|
remaining providers and retains existing named compatibility exports.
|
||||||
|
|
||||||
|
The tracked flight also gets a photo. The browser asks Planespotters for it
|
||||||
|
directly (`src/sources/live/standalone.js`, read by
|
||||||
|
`src/data/planespottersPhoto.js`), once per aircraft, and the CONTEXT panel's
|
||||||
|
CONTACTS view shows it under the flight's name with the photographer's credit
|
||||||
|
and a link to the photo's page. Military aircraft and a converted TR-3B are
|
||||||
|
never looked up. A flight lookup (type, route or photo) whose source failed
|
||||||
|
temporarily is asked again after a 60-second cool-down while the aircraft
|
||||||
|
stays in view or tracked; an answer, found or not, and a permanent refusal
|
||||||
|
stay final for the session. A route belongs to the callsign it was looked up
|
||||||
|
for, so a callsign change drops the old route. See `DATA_SOURCES.md`.
|
||||||
|
|
||||||
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
|
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
|
||||||
plugins and shared request helpers. Importing it starts no sockets or timers.
|
plugins and shared request helpers. Importing it starts no sockets or timers.
|
||||||
The existing aircraft normalizer is separately available through the portable
|
The existing aircraft normalizer is separately available through the portable
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
"node": "./server/providers/live.js"
|
"node": "./server/providers/live.js"
|
||||||
},
|
},
|
||||||
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
|
"./sources/adsb-lol": "./src/data/adsbLolFallback.js",
|
||||||
|
"./sources/planespotters": "./src/data/planespottersPhoto.js",
|
||||||
"./server/providers/places": {
|
"./server/providers/places": {
|
||||||
"node": "./server/providers/places.js"
|
"node": "./server/providers/places.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -242,6 +242,11 @@
|
|||||||
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
|
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
"planespotters-source": {
|
||||||
|
"exports": ["./sources/planespotters"],
|
||||||
|
"modules": ["src/data/planespottersPhoto.js"],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"place-providers": {
|
"place-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/places"],
|
"exports": ["./server/providers/places"],
|
||||||
@@ -1395,7 +1400,8 @@
|
|||||||
"src/sources/live/contract.js",
|
"src/sources/live/contract.js",
|
||||||
"src/sources/live/index.js",
|
"src/sources/live/index.js",
|
||||||
"src/sources/live/standalone.js",
|
"src/sources/live/standalone.js",
|
||||||
"src/sources/live/vessels.js"
|
"src/sources/live/vessels.js",
|
||||||
|
"src/data/planespottersPhoto.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -1462,7 +1468,8 @@
|
|||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/time/worldClock.js",
|
"src/time/worldClock.js",
|
||||||
"src/time/julian.js"
|
"src/time/julian.js",
|
||||||
|
"src/data/planespottersPhoto.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -3438,7 +3445,8 @@
|
|||||||
"src/layers/satellites/classes.js",
|
"src/layers/satellites/classes.js",
|
||||||
"src/layers/satellites/sensors.js",
|
"src/layers/satellites/sensors.js",
|
||||||
"src/data/ucsSatellites.js",
|
"src/data/ucsSatellites.js",
|
||||||
"src/data/imagingSensors.js"
|
"src/data/imagingSensors.js",
|
||||||
|
"src/data/planespottersPhoto.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -4098,7 +4106,8 @@
|
|||||||
"src/layers/satellites/classes.js",
|
"src/layers/satellites/classes.js",
|
||||||
"src/layers/satellites/sensors.js",
|
"src/layers/satellites/sensors.js",
|
||||||
"src/data/ucsSatellites.js",
|
"src/data/ucsSatellites.js",
|
||||||
"src/data/imagingSensors.js"
|
"src/data/imagingSensors.js",
|
||||||
|
"src/data/planespottersPhoto.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -5244,7 +5253,8 @@
|
|||||||
"src/layers/satellites/classes.js",
|
"src/layers/satellites/classes.js",
|
||||||
"src/layers/satellites/sensors.js",
|
"src/layers/satellites/sensors.js",
|
||||||
"src/data/ucsSatellites.js",
|
"src/data/ucsSatellites.js",
|
||||||
"src/data/imagingSensors.js"
|
"src/data/imagingSensors.js",
|
||||||
|
"src/data/planespottersPhoto.js"
|
||||||
],
|
],
|
||||||
"external": ["@mapbox/vector-tile", "pbf"]
|
"external": ["@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
const localHttpErrors = [];
|
const localHttpErrors = [];
|
||||||
@@ -825,6 +827,7 @@ try {
|
|||||||
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
|
// a legitimate retained snapshot cannot satisfy or invalidate a FIRST-fetch
|
||||||
// assertion when camera motion supersedes the manager's initial update.
|
// assertion when camera motion supersedes the manager's initial update.
|
||||||
const readinessPage = await browser.newPage();
|
const readinessPage = await browser.newPage();
|
||||||
|
await stubPlanespotters(readinessPage);
|
||||||
let deferredInstallationReadiness;
|
let deferredInstallationReadiness;
|
||||||
try {
|
try {
|
||||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
import { createScreencast, moveCamera } from './qa-journey-recorder.mjs';
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
@@ -77,6 +78,7 @@ const browser = await puppeteer.launch({
|
|||||||
|
|
||||||
async function openPage({ aircraft = false } = {}) {
|
async function openPage({ aircraft = false } = {}) {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
await page.setViewport({ width: 1440, height: 900 });
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -223,6 +224,7 @@ async function main() {
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
await page.setViewport({ width: 1440, height: 900 });
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
page.on('pageerror', (e) => errors.push(e.message));
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
const { lon, lat } = CENTER;
|
const { lon, lat } = CENTER;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
const getOpt = (name, fallback = null) => {
|
const getOpt = (name, fallback = null) => {
|
||||||
@@ -651,6 +652,7 @@ async function main() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
await page.setViewport(VIEWPORT);
|
await page.setViewport(VIEWPORT);
|
||||||
const consoleMessages = [];
|
const consoleMessages = [];
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Args (same shape as track-regression.mjs)
|
// Args (same shape as track-regression.mjs)
|
||||||
@@ -466,6 +467,7 @@ async function main() {
|
|||||||
const failedResponses = [];
|
const failedResponses = [];
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
await page.setViewport({ width: 1280, height: 800 });
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') {
|
if (msg.type() === 'error') {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Keep browser QA off third-party aircraft photo services.
|
||||||
|
*
|
||||||
|
* Tracking a civil flight asks Planespotters for the aircraft's photo. QA
|
||||||
|
* runs track synthetic hexes, so a real request only adds a third-party
|
||||||
|
* dependency and, when the service refuses a headless browser, a console
|
||||||
|
* error that fails the "no console errors" checks. Every photo lookup is
|
||||||
|
* answered with no photos instead, without leaving the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Origin of the aircraft photo lookup. */
|
||||||
|
export const PLANESPOTTERS_ORIGIN = 'https://api.planespotters.net';
|
||||||
|
|
||||||
|
/** The answer every stubbed lookup gets: no photos for this aircraft. */
|
||||||
|
export const PLANESPOTTERS_EMPTY_BODY = JSON.stringify({ photos: [] });
|
||||||
|
|
||||||
|
/** Whether a URL is a photo lookup the stub answers. */
|
||||||
|
export function isPlanespottersRequest(url) {
|
||||||
|
try {
|
||||||
|
return new URL(String(url)).origin === PLANESPOTTERS_ORIGIN;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer an intercepted photo lookup from a page that already uses request
|
||||||
|
* interception. Returns false for any other request, which the caller
|
||||||
|
* handles as before.
|
||||||
|
* @param {import('puppeteer').HTTPRequest} request
|
||||||
|
* @returns {boolean} Whether the request was answered.
|
||||||
|
*/
|
||||||
|
export function respondToPlanespotters(request) {
|
||||||
|
if (!isPlanespottersRequest(request.url())) return false;
|
||||||
|
request.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||||
|
body: PLANESPOTTERS_EMPTY_BODY,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub photo lookups in a page that does not intercept requests: wraps
|
||||||
|
* `fetch` before any app code runs, so a lookup never reaches the network.
|
||||||
|
* Composes with other `fetch` shims in either order.
|
||||||
|
* @param {import('puppeteer').Page} page
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function stubPlanespotters(page) {
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
(origin, body) => {
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const href =
|
||||||
|
typeof input === 'string' ? input : input?.url || String(input);
|
||||||
|
let target = '';
|
||||||
|
try {
|
||||||
|
target = new URL(href, location.href).origin;
|
||||||
|
} catch {
|
||||||
|
/* not a URL */
|
||||||
|
}
|
||||||
|
if (target === origin) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PLANESPOTTERS_ORIGIN,
|
||||||
|
PLANESPOTTERS_EMPTY_BODY,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { stubPlanespotters } from './qa-photo-stub.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||||||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||||||
@@ -186,6 +187,7 @@ async function main() {
|
|||||||
const httpErrors = [];
|
const httpErrors = [];
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
await stubPlanespotters(page);
|
||||||
await page.setViewport({ width: 1280, height: 800 });
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') {
|
if (msg.type() === 'error') {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { respondToPlanespotters } from './qa-photo-stub.mjs';
|
||||||
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs';
|
||||||
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||||||
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
|
||||||
@@ -287,6 +288,8 @@ async function main() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Aircraft photo lookups never reach the third party: no photos.
|
||||||
|
if (respondToPlanespotters(request)) return;
|
||||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||||
request.respond({
|
request.respond({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ export const DATA_CREDITS = [
|
|||||||
'copied, published, or incorporated into other databases without the ' +
|
'copied, published, or incorporated into other databases without the ' +
|
||||||
'explicit permission of David J Taylor, Edinburgh',
|
'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',
|
key: 'aisstream',
|
||||||
html:
|
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"/);
|
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', () => {
|
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||||
// UCS database manual, section 3: references name the version (the Excel
|
// UCS database manual, section 3: references name the version (the Excel
|
||||||
// file's name) and link www.ucsusa.org/satellite_database.
|
// file's name) and link www.ucsusa.org/satellite_database.
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ export const _driveFleetModelHandoffForTest =
|
|||||||
flightsLayer.testing._driveFleetModelHandoffForTest;
|
flightsLayer.testing._driveFleetModelHandoffForTest;
|
||||||
export const _ensureFleetModelForTest =
|
export const _ensureFleetModelForTest =
|
||||||
flightsLayer.testing._ensureFleetModelForTest;
|
flightsLayer.testing._ensureFleetModelForTest;
|
||||||
|
export const _resetEnrichmentForTest =
|
||||||
|
flightsLayer.testing._resetEnrichmentForTest;
|
||||||
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
export const mapAnalystRecord = flightsLayer.mapAnalystRecord;
|
||||||
export default flightsLayer;
|
export default flightsLayer;
|
||||||
+258
-2
@@ -1,7 +1,7 @@
|
|||||||
// src/data/flights.test.mjs
|
// src/data/flights.test.mjs
|
||||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||||
// Pure function — no viewer/DOM needed; imported directly.
|
// 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 assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import flightsLayer, {
|
import flightsLayer, {
|
||||||
@@ -14,9 +14,14 @@ import flightsLayer, {
|
|||||||
_setTrackedFlightRefreshStateForTest,
|
_setTrackedFlightRefreshStateForTest,
|
||||||
_floorGroundedDisplayPositionForTest,
|
_floorGroundedDisplayPositionForTest,
|
||||||
_clearDisplayFloorStateForTest,
|
_clearDisplayFloorStateForTest,
|
||||||
|
_resetEnrichmentForTest,
|
||||||
mapAnalystRecord,
|
mapAnalystRecord,
|
||||||
} from './flights.js';
|
} from './flights.js';
|
||||||
import { setMilitaryLayerActive } from './militaryRegistry.js';
|
import {
|
||||||
|
isMilitaryLayerActive,
|
||||||
|
registerMilitaryIcaos,
|
||||||
|
setMilitaryLayerActive,
|
||||||
|
} from './militaryRegistry.js';
|
||||||
import {
|
import {
|
||||||
GROUND_FLOOR_LIFT_M,
|
GROUND_FLOOR_LIFT_M,
|
||||||
reportMeshFloorCell,
|
reportMeshFloorCell,
|
||||||
@@ -31,6 +36,31 @@ import {
|
|||||||
initTrackedReadout,
|
initTrackedReadout,
|
||||||
} from './trackedReadout.js';
|
} 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 () => {
|
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
|
||||||
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||||
assert.equal(
|
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 () => {
|
test('real civil track path creates no native label and publishes every cached host line', async () => {
|
||||||
const icao24 = 'civ001';
|
const icao24 = 'civ001';
|
||||||
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
|
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(
|
assert.deepEqual(
|
||||||
released,
|
released,
|
||||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
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 {
|
} finally {
|
||||||
runtime.restore();
|
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 () => {
|
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
|
// The subject snapshot is captured once at selection. Selecting a
|
||||||
// callsign-less aircraft BEFORE adsbdb enrichment answers used to freeze the
|
// 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": "أسبوع/ث",
|
"1 wk/s": "أسبوع/ث",
|
||||||
"Play speed": "سرعة التشغيل",
|
"Play speed": "سرعة التشغيل",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
"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",
|
"1 wk/s": "1 Wo./s",
|
||||||
"Play speed": "Wiedergabegeschwindigkeit",
|
"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",
|
"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",
|
"1 wk/s": "1 sem/s",
|
||||||
"Play speed": "Velocidad de reproducción",
|
"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",
|
"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",
|
"1 wk/s": "1 sem/s",
|
||||||
"Play speed": "Vitesse de lecture",
|
"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",
|
"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",
|
"1 wk/s": "1 sett/s",
|
||||||
"Play speed": "Velocità di riproduzione",
|
"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",
|
"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週間/秒",
|
"1 wk/s": "1週間/秒",
|
||||||
"Play speed": "再生速度",
|
"Play speed": "再生速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
"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",
|
"1 wk/s": "1 sem/s",
|
||||||
"Play speed": "Velocidade de reprodução",
|
"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",
|
"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 нед/с",
|
"1 wk/s": "1 нед/с",
|
||||||
"Play speed": "Скорость воспроизведения",
|
"Play speed": "Скорость воспроизведения",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
"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 тиж/с",
|
"1 wk/s": "1 тиж/с",
|
||||||
"Play speed": "Швидкість відтворення",
|
"Play speed": "Швидкість відтворення",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
"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 周/秒",
|
"1 wk/s": "1 周/秒",
|
||||||
"Play speed": "播放速度",
|
"Play speed": "播放速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
"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 週/秒",
|
"1 wk/s": "1 週/秒",
|
||||||
"Play speed": "播放速度",
|
"Play speed": "播放速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
"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",
|
"Ⅱ PAUSE",
|
||||||
"≥ {value} MW",
|
"≥ {value} MW",
|
||||||
"▲ {frp} MW",
|
"▲ {frp} MW",
|
||||||
"SATELLITE"
|
"SATELLITE",
|
||||||
|
"Photo © {photographer}"
|
||||||
],
|
],
|
||||||
"it": [
|
"it": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
|
|||||||
@@ -182,6 +182,28 @@ export function createPanel({ state: layerState, parts }) {
|
|||||||
: null;
|
: 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() {
|
function renderResults() {
|
||||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||||
ensurePanel();
|
ensurePanel();
|
||||||
@@ -191,6 +213,7 @@ export function createPanel({ state: layerState, parts }) {
|
|||||||
kind: 'results',
|
kind: 'results',
|
||||||
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||||
subjectNames: presentNames(subject.memberNames),
|
subjectNames: presentNames(subject.memberNames),
|
||||||
|
photo: presentPhoto(subject),
|
||||||
navigation: presentNavigation(),
|
navigation: presentNavigation(),
|
||||||
rows: cohorts.map(presentRow),
|
rows: cohorts.map(presentRow),
|
||||||
note: t(
|
note: t(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createPanel } from './panel.js';
|
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', () => {
|
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
|
||||||
const cohort = (id, source, sourceStatus) => ({
|
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?.();
|
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). */
|
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||||
unwatchAdoptions: null,
|
unwatchAdoptions: null,
|
||||||
subject: null,
|
subject: null,
|
||||||
|
/** The tracked flight's photo with the subject it belongs to, or null. */
|
||||||
|
subjectPhoto: null,
|
||||||
results: null,
|
results: null,
|
||||||
visual: null,
|
visual: null,
|
||||||
/** Whether layer views replace the panel's initial markup yet. */
|
/** 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.
|
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
|
||||||
* @param {{layerId: string, id: string, label: string}} subject Current subject.
|
* @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
|
* @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).
|
* has no tracked subject (e.g. a cross-layer handoff is in flight).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function resolveSubjectLabel(subject) {
|
function resolveSubjectLabel(subject, tracked = trackedSubjectOf(subject)) {
|
||||||
if (!parts.navigation.isFlightLayer(subject?.layerId))
|
return tracked?.label || subject?.label;
|
||||||
return 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 =
|
const layer =
|
||||||
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
||||||
const tracked = layer.getTrackedSubject?.();
|
const tracked = layer.getTrackedSubject?.();
|
||||||
if (tracked?.label && String(tracked.id) === String(subject.id))
|
return tracked && String(tracked.id) === String(subject.id)
|
||||||
return tracked.label;
|
? tracked
|
||||||
return subject.label;
|
: 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. */
|
/** 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;
|
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
|
||||||
else if (presence === SUBJECT_PRESENCE.MISSING)
|
else if (presence === SUBJECT_PRESENCE.MISSING)
|
||||||
layerState.subjectMissing = true;
|
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;
|
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 };
|
layerState.subject = { ...layerState.subject, position, label: nextLabel };
|
||||||
const movementM = layerState.lastEvaluatedPosition
|
const movementM = layerState.lastEvaluatedPosition
|
||||||
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
|
? 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
|
// 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
|
// page rotation happened to repaint it. Gated on an ACTUAL change so the
|
||||||
// common no-op refresh (every 750 ms) still costs no repaint.
|
// 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);
|
parts.rendering.scheduleDirectionOverlayUpdate(force);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -502,6 +544,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
militaryInstallationsLayer.setContextAnchor?.(null);
|
militaryInstallationsLayer.setContextAnchor?.(null);
|
||||||
layerState.autoFocusRetryPending = false;
|
layerState.autoFocusRetryPending = false;
|
||||||
layerState.subject = null;
|
layerState.subject = null;
|
||||||
|
layerState.subjectPhoto = null;
|
||||||
layerState.subjectMissing = false;
|
layerState.subjectMissing = false;
|
||||||
layerState.results = null;
|
layerState.results = null;
|
||||||
layerState.navigationHistory = [];
|
layerState.navigationHistory = [];
|
||||||
@@ -528,6 +571,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
resolveSubjectPosition,
|
resolveSubjectPosition,
|
||||||
collectionSubjectPosition,
|
collectionSubjectPosition,
|
||||||
resolveSubjectLabel,
|
resolveSubjectLabel,
|
||||||
|
resolveSubjectPhoto,
|
||||||
refreshSelectedSubject,
|
refreshSelectedSubject,
|
||||||
subjectFromContext,
|
subjectFromContext,
|
||||||
markSubjectEvicted,
|
markSubjectEvicted,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||||
|
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
ENRICH_MAX_INFLIGHT,
|
ENRICH_MAX_INFLIGHT,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
ENRICH_AMBIENT_REFILL_TOKENS,
|
ENRICH_AMBIENT_REFILL_TOKENS,
|
||||||
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
ENRICH_AMBIENT_REFILL_WINDOW_MS,
|
||||||
ENRICH_AMBIENT_PER_SWEEP,
|
ENRICH_AMBIENT_PER_SWEEP,
|
||||||
|
ENRICH_RETRY_COOLDOWN_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
export function createEnrichment({
|
export function createEnrichment({
|
||||||
@@ -17,11 +19,62 @@ export function createEnrichment({
|
|||||||
layer,
|
layer,
|
||||||
resolveAsset,
|
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.lifetime.signal.aborted) return;
|
||||||
if (flightState._enrichSeen.has(key)) return;
|
if (_enrichHeld(key)) return;
|
||||||
flightState._enrichSeen.add(key);
|
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
|
// 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.
|
// backlog can never delay the plane the user just clicked or zoomed into.
|
||||||
if (priority) flightState._enrichQueue.unshift(job);
|
if (priority) flightState._enrichQueue.unshift(job);
|
||||||
@@ -61,9 +114,24 @@ export function createEnrichment({
|
|||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then(
|
||||||
if (!lifetime.signal.aborted && data && data.found) job.onData(data);
|
(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(() => {
|
.catch(() => {
|
||||||
/* enrichment never surfaces errors */
|
/* enrichment never surfaces errors */
|
||||||
})
|
})
|
||||||
@@ -119,6 +187,14 @@ export function createEnrichment({
|
|||||||
(data) => {
|
(data) => {
|
||||||
const meta = flightState.records.data.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
if (!meta) return;
|
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;
|
meta.airline = data.airline || meta.airline;
|
||||||
if (data.origin && data.destination)
|
if (data.origin && data.destination)
|
||||||
meta.route = { origin: data.origin, destination: 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
|
); // 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
|
/** QA seam: headless harnesses (scripts/qa-enrich-ambient.mjs) shrink the
|
||||||
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
|
* bucket knobs via window.__GEV_ENRICH_AMBIENT_QA = {ceil, refillTokens,
|
||||||
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
|
* windowMs} — they cannot wait out a real 5-minute window. Read lazily each
|
||||||
@@ -196,7 +297,9 @@ export function createEnrichment({
|
|||||||
);
|
);
|
||||||
const cand = [];
|
const cand = [];
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
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 (!/^[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 (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
|
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||||
@@ -233,6 +336,7 @@ export function createEnrichment({
|
|||||||
_drainEnrich,
|
_drainEnrich,
|
||||||
_requestTypeEnrichment,
|
_requestTypeEnrichment,
|
||||||
_requestRouteEnrichment,
|
_requestRouteEnrichment,
|
||||||
|
_requestPhotoEnrichment,
|
||||||
_ambientBudgetKnobs,
|
_ambientBudgetKnobs,
|
||||||
_refillAmbientBudget,
|
_refillAmbientBudget,
|
||||||
_sweepAmbientEnrichment,
|
_sweepAmbientEnrichment,
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export function createLifecycle({
|
|||||||
flightState._displayFloorState.clear();
|
flightState._displayFloorState.clear();
|
||||||
flightState._enrichQueue.length = 0;
|
flightState._enrichQueue.length = 0;
|
||||||
flightState._enrichSeen.clear();
|
flightState._enrichSeen.clear();
|
||||||
|
flightState._enrichRetryAfter.clear();
|
||||||
if (flightState._enrichDripTimer) {
|
if (flightState._enrichDripTimer) {
|
||||||
clearTimeout(flightState._enrichDripTimer);
|
clearTimeout(flightState._enrichDripTimer);
|
||||||
flightState._enrichDripTimer = null;
|
flightState._enrichDripTimer = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createCivilFlightLayer } from './index.js';
|
|||||||
import { createFlightState } from './state.js';
|
import { createFlightState } from './state.js';
|
||||||
import { createEnrichment } from './enrichment.js';
|
import { createEnrichment } from './enrichment.js';
|
||||||
import { createTracking } from './tracking.js';
|
import { createTracking } from './tracking.js';
|
||||||
|
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||||
|
|
||||||
function services() {
|
function services() {
|
||||||
const names = [
|
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', () => {
|
test('constructing independent flight layers performs no source request and isolates selection', () => {
|
||||||
let requests = 0;
|
let requests = 0;
|
||||||
const source = {
|
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 flightState = createFlightState({ source, services: services() });
|
||||||
const enrichment = createEnrichment({ flightState, services: {}, parts: {} });
|
const enrichment = createEnrichment({
|
||||||
|
flightState,
|
||||||
|
services: enrichServices(),
|
||||||
|
parts: {},
|
||||||
|
});
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
enrichment._enqueueEnrich(
|
enrichment._enqueueEnrich(
|
||||||
'test',
|
'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(() =>
|
assert.doesNotThrow(() =>
|
||||||
enrichment._enqueueEnrich('test', {}, () => assert.fail()),
|
enrichment._enqueueEnrich('test', {}, () => assert.fail()),
|
||||||
);
|
);
|
||||||
@@ -146,3 +163,336 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
|
|||||||
'0°',
|
'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;
|
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
|
// 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
|
// category field is 0/"no info" for ~94% of planes, so ambient classification
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function createQueries({
|
|||||||
layer,
|
layer,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
const { isTr3b, tr3bTypeLabel, tr3bAircraftClass } =
|
||||||
|
services.aircraftPresentation;
|
||||||
const { formatFlightLevel } = services.labels;
|
const { formatFlightLevel } = services.labels;
|
||||||
const { isMilitaryIcao } = services.militaryRegistry;
|
const { isMilitaryIcao } = services.militaryRegistry;
|
||||||
const { applyTrackedCameraFrame } = services.camera;
|
const { applyTrackedCameraFrame } = services.camera;
|
||||||
@@ -208,6 +209,12 @@ export function createQueries({
|
|||||||
destination: { ...info.route.destination },
|
destination: { ...info.route.destination },
|
||||||
}
|
}
|
||||||
: null,
|
: 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.
|
* 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}
|
* @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.
|
* Tracked aircraft info, or null when nothing is tracked. `photo` is the
|
||||||
|
* airframe's credited Planespotters photo, or null.
|
||||||
*/
|
*/
|
||||||
getTrackedInfo() {
|
getTrackedInfo() {
|
||||||
if (!flightState._trackedIcao) return null;
|
if (!flightState._trackedIcao) return null;
|
||||||
@@ -906,6 +914,8 @@ export function createQueries({
|
|||||||
_toCleanText(described.registration) ||
|
_toCleanText(described.registration) ||
|
||||||
described.icao24,
|
described.icao24,
|
||||||
position: Cesium.Cartesian3.clone(described.position),
|
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;
|
!!prevMeta && (prevMeta.onGround === true) !== onGround;
|
||||||
// Store flight metadata for click-to-track labels
|
// Store flight metadata for click-to-track labels
|
||||||
const cat = stickyNumber(category, prevMeta?.category, null);
|
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 = {
|
const meta = {
|
||||||
sourceReference: observation.reference,
|
sourceReference: observation.reference,
|
||||||
observedReceiptMs: Date.now(),
|
observedReceiptMs: Date.now(),
|
||||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
callsign: nextCallsign,
|
||||||
altitude: alt,
|
altitude: alt,
|
||||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||||
// untouched aviation `altitude` — never rename/replace it (labels,
|
// untouched aviation `altitude` — never rename/replace it (labels,
|
||||||
@@ -256,8 +262,11 @@ export class FlightRecords {
|
|||||||
typeCode: prevMeta?.typeCode ?? null,
|
typeCode: prevMeta?.typeCode ?? null,
|
||||||
typeName: prevMeta?.typeName ?? null,
|
typeName: prevMeta?.typeName ?? null,
|
||||||
registration: prevMeta?.registration ?? null,
|
registration: prevMeta?.registration ?? null,
|
||||||
airline: prevMeta?.airline ?? null,
|
airline: routeHeld ? (prevMeta.airline ?? null) : null,
|
||||||
route: prevMeta?.route ?? 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
|
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||||
// dead-reckoned billboard position for any consumer that needs the
|
// 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.meta.renderAltitudeM, 1040);
|
||||||
assert.equal(first.fixEpochMs, 1700000000000);
|
assert.equal(first.fixEpochMs, 1700000000000);
|
||||||
first.meta.typeCode = 'B738';
|
first.meta.typeCode = 'B738';
|
||||||
|
first.meta.photo = { src: 'https://t.plnspttrs.net/1/2_280.jpg' };
|
||||||
const second = store.receive(
|
const second = store.receive(
|
||||||
observation({
|
observation({
|
||||||
callsign: '',
|
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.velocity, 120);
|
||||||
assert.equal(second.meta.true_track, 90);
|
assert.equal(second.meta.true_track, 90);
|
||||||
assert.equal(second.meta.typeCode, 'B738');
|
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.equal(Object.hasOwn(second.meta, 'cullPosition'), false);
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta);
|
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.squawk, '2000');
|
||||||
assert.equal(next.meta.emergency, null);
|
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)
|
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||||
if (isTracked && flightState._trackedEntity) {
|
if (isTracked && flightState._trackedEntity) {
|
||||||
tracking._updateTrackedLabelModel(icao24);
|
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();
|
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;
|
flightState._enrichAmbientBudget = ENRICH_AMBIENT_BUDGET_CEIL;
|
||||||
|
|
||||||
/** Epoch ms the bucket last accounted a refill window from (0 = unset). */
|
/** 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) {
|
async function _ensureFleetModelForTest(icao24) {
|
||||||
await parts.rendering._ensureModel(icao24);
|
await parts.rendering._ensureModel(icao24);
|
||||||
return flightState._models.get(icao24) || null;
|
return flightState._models.get(icao24) || null;
|
||||||
@@ -212,5 +224,6 @@ export function createTesting({
|
|||||||
_trackedBillboardColorForTest,
|
_trackedBillboardColorForTest,
|
||||||
_driveFleetModelHandoffForTest,
|
_driveFleetModelHandoffForTest,
|
||||||
_ensureFleetModelForTest,
|
_ensureFleetModelForTest,
|
||||||
|
_resetEnrichmentForTest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1060,7 @@ export function createTracking({
|
|||||||
// starts one.
|
// starts one.
|
||||||
parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue
|
parts.enrichment._requestTypeEnrichment(icao24, true); // tracked plane — front of the enrichment queue
|
||||||
parts.enrichment._requestRouteEnrichment(icao24);
|
parts.enrichment._requestRouteEnrichment(icao24);
|
||||||
|
parts.enrichment._requestPhotoEnrichment(icao24);
|
||||||
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
|
// Round 2 (owner): grounded contacts get trails too — a landed-but-taxiing
|
||||||
// aircraft's history is retrievable on select. Grounded flights positions
|
// aircraft's history is retrievable on select. Grounded flights positions
|
||||||
// are already surface-clamped (the surfaceM chain), so seeds/appends drape.
|
// 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.squawk, null);
|
||||||
assert.equal(quiet.emergency, 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,
|
readsbIdentities,
|
||||||
} from './aircraft.js';
|
} from './aircraft.js';
|
||||||
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
|
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
|
||||||
|
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
|
||||||
|
|
||||||
const defaultFetch = (...args) => globalThis.fetch(...args);
|
const defaultFetch = (...args) => globalThis.fetch(...args);
|
||||||
const header = (response, name) => response.headers?.get?.(name);
|
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) {
|
function openSkyError(response) {
|
||||||
const error = httpError(response, 'Flights');
|
const error = httpError(response, 'Flights');
|
||||||
const mode = String(
|
const mode = String(
|
||||||
@@ -106,6 +133,8 @@ export function createFlightSource({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async getEnrichment(query, { signal } = {}) {
|
async getEnrichment(query, { signal } = {}) {
|
||||||
|
if (query.kind === 'photo')
|
||||||
|
return planespottersPhoto(fetchImpl, query.id, signal);
|
||||||
if (!['type', 'route'].includes(query.kind))
|
if (!['type', 'route'].includes(query.kind))
|
||||||
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
|
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
|
||||||
const { response, payload } = await readResponse(
|
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',
|
'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;
|
margin: 0 0 7px;
|
||||||
color: #fff;
|
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 {
|
.military-awareness-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import AwarenessNames from './AwarenessNames.vue';
|
|||||||
const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore());
|
const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore());
|
||||||
const user = { origin: 'user' };
|
const user = { origin: 'user' };
|
||||||
const root = ref(null);
|
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
|
// 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
|
// focus from a node Vue moves, removes or disables, so the control is found
|
||||||
@@ -52,6 +59,10 @@ onUpdated(() => {
|
|||||||
</div>
|
</div>
|
||||||
<template v-else-if="view.kind === 'results'">
|
<template v-else-if="view.kind === 'results'">
|
||||||
<div class="military-awareness-subject">{{ view.subject }}</div>
|
<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" />
|
<AwarenessNames v-if="view.subjectNames" :names="view.subjectNames" />
|
||||||
<div class="military-awareness-controls" role="group" :aria-label="view.navigation.label">
|
<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>
|
<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