diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 759dff105..b1f44764c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -31,6 +31,7 @@ How to read this: | **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 | | **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 that names one, linked to its Planespotters page; "Planespotters.net" in Data attribution | +| **Open Vessel Photos** (`openvesselphotos.org/v1/mmsi/{mmsi}`) | One photo of the selected vessel, by MMSI, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI | Free, keyless public API ([README](https://github.com/louptheron/openvesselphotos)); each photo keeps the open licence its author chose (CC BY, CC BY-SA, CC0, or public domain), given with it in the API | "Photo: {photographer} · {licence}" beside every photo, linked to the vessel's Open Vessel Photos page; "Open Vessel Photos" in Data attribution | | **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" | | **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) | @@ -238,7 +239,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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`, and whose page is an `https://www.planespotters.net` URL 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, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), 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. +- **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`, and whose page is an `https://www.planespotters.net` URL 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 (see Open Vessel Photos below), 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, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), 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. +- **Open Vessel Photos.** When you select a vessel with a nine-digit MMSI, the browser asks `https://openvesselphotos.org/v1/mmsi/{mmsi}` directly (the API is keyless and answers cross-origin, so no key and no server proxy are involved; account credentials are never sent) for that vessel only, once per MMSI per session, with an 8-second timeout. A miss (a 404 or a vessel without photos) is remembered for the session; a failed request may be asked again after five minutes. The first photo whose image is an `https://openvesselphotos.org` URL and which names a licence is shown, at the 800 px size, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else (`src/data/openVesselPhoto.js`, `src/layers/vessels/photos.js`). The image is hotlinked from the URL the API gives (never copied or re-hosted); the photographer and licence are visible text next to it ("Photo: {photographer} · {licence}", or "Photo · {licence}" when the answer names no one), and the photo and credit link to the vessel's Open Vessel Photos page with `target="_blank" rel="noopener"`. 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. - **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. @@ -686,4 +688,4 @@ NOAA density imagery with the raw GLM product. ## 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, 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. +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, Open Vessel Photos, 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 02c1996fb..d62662e29 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3199,6 +3199,15 @@ 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`. +A selected vessel gets a photo the same way. The browser asks Open Vessel +Photos for it by MMSI (`fetchOpenVesselPhoto` in +`src/sources/live/standalone.js`, read by `src/data/openVesselPhoto.js`), once +per MMSI (`src/layers/vessels/photos.js`), and the CONTACTS view shows it under +the vessel's name and MMSI ("NAME · MMSI 366999709") with the photographer, +the photo's open licence and a link to the vessel's page. A vessel without an +MMSI is never looked up; a miss stays final for the session, and a failed +lookup is asked again after five minutes. + `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. The existing aircraft normalizer is separately available through the portable diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b49781778..b580e5193 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -385,6 +385,8 @@ "src/layers/vessels/index.js", "src/layers/vessels/ingestion.js", "src/layers/vessels/lifecycle.js", + "src/layers/vessels/photos.js", + "src/layers/vessels/photos.test.mjs", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", "src/layers/vessels/rendering.js", @@ -1335,6 +1337,8 @@ "src/app/sourceComposition.js", "src/app/sourceComposition.test.mjs", "src/data/dataCredits.js", + "src/data/openVesselPhoto.js", + "src/data/openVesselPhoto.test.mjs", "src/data/layerState.js", "src/data/layerState.test.mjs", "src/data/spriteOrder.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 873f37f80..ae86026d5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1474,7 +1474,8 @@ "src/sources/live/index.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": [] }, @@ -1696,7 +1697,9 @@ "src/i18n/locales/sv.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": ["cesium"] }, @@ -3757,7 +3760,9 @@ "src/data/ucsSatellites.js", "src/data/imagingSensors.js", "src/data/planespottersPhoto.js", - "src/ui/stores/dataLibrary.js" + "src/ui/stores/dataLibrary.js", + "src/data/openVesselPhoto.js", + "src/layers/vessels/photos.js" ], "external": [ "hls.js", @@ -4467,7 +4472,9 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": [ "hls.js", @@ -5719,7 +5726,8 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a452fa0cf..c9281fd4a 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -141,6 +141,13 @@ export const DATA_CREDITS = [ 'Planespotters.net ' + '· each photo is © its photographer, credited beside it', }, + { + key: 'openvesselphotos', + html: + 'Vessel photos: ' + + 'Open Vessel Photos ' + + "· each photo keeps its author's open licence, credited beside it", + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 8e7179338..3c9fb0fee 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -51,6 +51,13 @@ test('Planespotters is credited with a plain followable link', () => { assert.doesNotMatch(credit.html, /nofollow/); }); +test('Open Vessel Photos is credited with a plain followable link', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'openvesselphotos'); + assert.ok(credit, 'the selected vessel photos come from Open Vessel Photos'); + assert.match(credit.html, /href="https:\/\/openvesselphotos\.org"/); + assert.doesNotMatch(credit.html, /nofollow/); +}); + test('the UCS Satellite Database is acknowledged with its version and webpage', () => { // UCS database manual, section 3: references name the version (the Excel // file's name) and link www.ucsusa.org/satellite_database. diff --git a/src/data/openVesselPhoto.js b/src/data/openVesselPhoto.js new file mode 100644 index 000000000..d0749b1aa --- /dev/null +++ b/src/data/openVesselPhoto.js @@ -0,0 +1,135 @@ +/** + * @module openVesselPhoto + * @description Reads an Open Vessel Photos answer into the one photo every + * surface shows. Open Vessel Photos (https://openvesselphotos.org) serves a + * free, keyless JSON and image API by MMSI. Every photo keeps the open licence + * its author chose (CC BY, CC BY-SA, CC0 or public domain), so it is shown + * with its credit and licence and links back to the vessel's page. A photo + * whose image is not on Open Vessel Photos' own host, or whose answer names + * no licence, is "no photo": nothing arbitrary is ever shown or linked. + * + * Accepts the upstream answer (`GET https://openvesselphotos.org/v1/mmsi/{mmsi}`, + * `{mmsi, photos: [...]}`) and an already-read photo + * (`{found, src, width, height, photographer, licence, link}`). + * Pure: no fetch, no DOM. + */ + +/** The only host a photo may come from or link to. */ +const HOST = 'openvesselphotos.org'; +/** The API's per-vessel answer. */ +const API = `https://${HOST}/v1/mmsi/`; +/** The image width asked for: sharp in the panel on a 2× screen, ~60 KB. */ +const IMAGE_SIZE = '800'; + +const NO_PHOTO = Object.freeze({ found: false }); + +/** A vessel's MMSI: nine digits. */ +export const MMSI = /^\d{9}$/; + +/** The API answer for one MMSI. */ +export function openVesselPhotosUrl(mmsi) { + return API + mmsi; +} + +/** The vessel's page on Open Vessel Photos. */ +function vesselPage(mmsi) { + return `https://${HOST}/vessels/mmsi/${mmsi}`; +} + +/** The parsed `https:` URL on the Open Vessel Photos host, or null. */ +function ownUrl(value) { + if (typeof value !== 'string' || !value) return null; + let url; + try { + url = new URL(value); + } catch { + return null; + } + return url.protocol === 'https:' && + !url.username && + !url.password && + !url.port && + url.hostname === HOST + ? url + : null; +} + +function dimension(value) { + const number = Number(value); + return value != null && Number.isFinite(number) && number > 0 + ? Math.round(number) + : null; +} + +function text(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +/** One upstream photo of the vessel `mmsi`, checked. */ +function checkedUpstream(photo, mmsi) { + if (!photo || typeof photo !== 'object') return null; + const image = ownUrl(photo.urls?.mmsi); + const licence = text(photo.licenceName) || text(photo.licence); + if (!image || !licence) return null; + image.search = ''; + image.hash = ''; + image.searchParams.set('size', IMAGE_SIZE); + // The answer gives the full image's size; the 800 px one keeps its shape. + const width = dimension(photo.width); + const height = dimension(photo.height); + const scale = + width && width > Number(IMAGE_SIZE) ? Number(IMAGE_SIZE) / width : 1; + return { + found: true, + src: image.href, + width: width ? Math.round(width * scale) : null, + height: height ? Math.round(height * scale) : null, + photographer: text(photo.credit), + licence, + link: vesselPage(mmsi), + }; +} + +/** An already-read photo, checked again. */ +function checkedRead(photo) { + const image = ownUrl(photo.src); + const page = ownUrl(photo.link); + const licence = text(photo.licence); + if (!image || !page || !licence) return null; + return { + found: true, + src: image.href, + width: dimension(photo.width), + height: dimension(photo.height), + photographer: text(photo.photographer), + licence, + link: page.href, + }; +} + +/** + * Read an Open Vessel Photos answer into the first photo that passes the + * checks. + * @param {*} payload Upstream answer or already-read photo. + * @param {?string} [requestedMmsi] The MMSI asked for; an upstream answer + * naming another is no photo. + * @returns {{found: true, src: string, width: ?number, height: ?number, + * photographer: string, licence: string, link: string} | {found: false}} + */ +export function parseOpenVesselPhoto(payload, requestedMmsi = null) { + if (!payload || typeof payload !== 'object') return NO_PHOTO; + if (Array.isArray(payload.photos)) { + const mmsi = String(payload.mmsi ?? ''); + if (!MMSI.test(mmsi)) return NO_PHOTO; + // An answer about another vessel is no photo of this one. + if (requestedMmsi != null && mmsi !== String(requestedMmsi)) + return NO_PHOTO; + for (const photo of payload.photos) { + const checked = checkedUpstream(photo, mmsi); + if (checked) return checked; + } + return NO_PHOTO; + } + if (payload.found !== true) return NO_PHOTO; + return checkedRead(payload) || NO_PHOTO; +} diff --git a/src/data/openVesselPhoto.test.mjs b/src/data/openVesselPhoto.test.mjs new file mode 100644 index 000000000..e43fe397c --- /dev/null +++ b/src/data/openVesselPhoto.test.mjs @@ -0,0 +1,120 @@ +// src/data/openVesselPhoto.test.mjs — an Open Vessel Photos answer is read +// into one credited, licensed photo, and nothing outside Open Vessel Photos' +// own host is ever shown or linked. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + openVesselPhotosUrl, + parseOpenVesselPhoto, +} from './openVesselPhoto.js'; + +const UPSTREAM = Object.freeze({ + mmsi: '366999709', + name: '01 USCGC Sockeye WPB87337', + photos: [ + { + n: 1, + urls: { + cfr: null, + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + credit: 'Gordon Leggett', + licence: 'CC-BY-4.0', + licenceName: 'CC BY 4.0', + width: 1600, + height: 1200, + }, + ], +}); + +const PHOTO = Object.freeze({ + found: true, + src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800', + width: 800, + height: 600, + photographer: 'Gordon Leggett', + licence: 'CC BY 4.0', + link: 'https://openvesselphotos.org/vessels/mmsi/366999709', +}); + +const withPhoto = (overrides) => ({ + ...UPSTREAM, + photos: [{ ...UPSTREAM.photos[0], ...overrides }], +}); + +test('the upstream answer maps to the 800 px image, its credit, licence and page', () => { + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM), PHOTO); + assert.equal( + openVesselPhotosUrl('366999709'), + 'https://openvesselphotos.org/v1/mmsi/366999709', + ); +}); + +test('an image narrower than 800 px keeps its own size', () => { + const photo = parseOpenVesselPhoto(withPhoto({ width: 640, height: 480 })); + assert.equal(photo.width, 640); + assert.equal(photo.height, 480); +}); + +test('an already-read photo passes through its checks again', () => { + assert.deepEqual(parseOpenVesselPhoto(PHOTO), PHOTO); + assert.deepEqual(parseOpenVesselPhoto({ ...PHOTO, licence: '' }), { + found: false, + }); +}); + +test('a photo off the Open Vessel Photos host, or without a licence, is no photo', () => { + for (const overrides of [ + { urls: { mmsi: 'https://example.com/1.jpg' } }, + { + urls: { + mmsi: 'http://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + }, + { urls: { mmsi: 'https://openvesselphotos.org.evil.com/1.jpg' } }, + { urls: { mmsi: 'https://user@openvesselphotos.org/1.jpg' } }, + { licence: '', licenceName: '' }, + ]) + assert.deepEqual(parseOpenVesselPhoto(withPhoto(overrides)), { + found: false, + }); + assert.deepEqual( + parseOpenVesselPhoto({ ...PHOTO, link: 'https://example.com/' }), + { found: false }, + ); +}); + +test('a vessel without photos, or an answer with a bad MMSI, is no photo', () => { + assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, photos: [] }), { + found: false, + }); + assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, mmsi: '12/../x' }), { + found: false, + }); + assert.deepEqual(parseOpenVesselPhoto(null), { found: false }); +}); + +test('the next photo stands in when the first fails its checks', () => { + const second = { + ...UPSTREAM.photos[0], + n: 2, + urls: { + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg', + }, + }; + const photo = parseOpenVesselPhoto({ + ...UPSTREAM, + photos: [{ ...UPSTREAM.photos[0], licenceName: '', licence: '' }, second], + }); + assert.equal( + photo.src, + 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg?size=800', + ); +}); + +test('an answer about another vessel than the one asked for is no photo', () => { + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '366999709'), PHOTO); + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '227000101'), { + found: false, + }); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 5b0da6060..e5b833615 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3804,6 +3804,8 @@ "(+{minutes} min late)": "(تأخير {minutes} د)", "Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار", "Photo © {photographer}": "صورة © {photographer}", + "Photo: {photographer} · {licence}": "صورة: {photographer} · {licence}", + "Photo · {licence}": "صورة · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", "The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 55b9301f9..060244aa6 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3480,6 +3480,8 @@ "(+{minutes} min late)": "(+{minutes} min Verspätung)", "Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt", "The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt", "The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3f7a5e7b0..dda1633aa 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de retraso)", "Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", "The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 870d75390..161ce716a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de retard)", "Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train", "Photo © {photographer}": "Photo © {photographer}", + "Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}", + "Photo · {licence}": "Photo · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue", "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b0bd1eac2..1e6066ab9 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3385,6 +3385,8 @@ "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", "Photo © {photographer}": "צילום © {photographer}", + "Photo: {photographer} · {licence}": "צילום: {photographer} · {licence}", + "Photo · {licence}": "צילום · {licence}", "1 h/s": "שעה/שנ׳", "1 d/s": "יום/שנ׳", "1 wk/s": "שבוע/שנ׳", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9a840d7c1..2864e1d17 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min di ritardo)", "Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5dc71c358..20a65fcb4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "({minutes}分遅れ)", "Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください", "Photo © {photographer}": "写真 © {photographer}", + "Photo: {photographer} · {licence}": "写真:{photographer} · {licence}", + "Photo · {licence}": "写真 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています", "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています", "The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index cb34c1594..8b1e5e7ae 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de atraso)", "Exit cockpit to fly to a train": "Saia da cabine para voar até um trem", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida", "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}", "The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index de517a550..6a366e4b8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3642,6 +3642,8 @@ "(+{minutes} min late)": "(+{minutes} мин опоздания)", "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", + "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", + "Photo · {licence}": "Фото · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место", "The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 3d4c93d9b..6aa87a8d4 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3472,6 +3472,8 @@ "(+{minutes} min late)": "(+{minutes} min försenad)", "Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position", "The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Den delade militärflygningen rapporterar inte just nu – visar senast kända position", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f64f6e8e0..acb9e92a8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3642,6 +3642,8 @@ "(+{minutes} min late)": "(+{minutes} хв запізнення)", "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", + "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", + "Photo · {licence}": "Фото · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце", "The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index da34f1c26..86ecd4f40 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "(晚点 {minutes} 分钟)", "Exit cockpit to fly to a train": "请先退出座舱,再飞往列车", "Photo © {photographer}": "照片 © {photographer}", + "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", + "Photo · {licence}": "照片 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置", "The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 52e6b2033..6f7bc4801 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", + "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", + "Photo · {licence}": "照片 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置", "The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index b90685e68..e9735a668 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -625,6 +625,7 @@ "▲ {frp} MW", "SATELLITE", "Photo © {photographer}", + "Photo · {licence}", "PACK", "{count} packs", "Orthophoto", diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index bd5078499..9d9bf75af 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -182,7 +182,22 @@ export function createPanel({ state: layerState, parts }) { : null; } - /** The tracked airliner's photo with its credit, or null. The subject + /** The credit line: the photographer, and an open photo's licence. A + * photo whose answer names no photographer shows no photographer. */ + function photoCredit(photo) { + if (photo.licence) + return photo.photographer + ? t('Photo: {photographer} · {licence}', { + photographer: photo.photographer, + licence: photo.licence, + }) + : t('Photo · {licence}', { licence: photo.licence }); + return photo.photographer + ? t('Photo © {photographer}', { photographer: photo.photographer }) + : ''; + } + + /** The subject'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; @@ -198,10 +213,7 @@ export function createPanel({ state: layerState, parts }) { width: photo.width ?? null, height: photo.height ?? null, link: photo.link, - // A photo whose answer names no photographer shows no credit line. - credit: photo.photographer - ? t('Photo © {photographer}', { photographer: photo.photographer }) - : '', + credit: photoCredit(photo), }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs index ec361080d..7d21f8fa4 100644 --- a/src/layers/awareness/panel.test.mjs +++ b/src/layers/awareness/panel.test.mjs @@ -113,7 +113,52 @@ test('the Contacts view has no photo without one, or with another subject’s', ); }); -test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => { +test('the selected vessel shows its own photo, by its id', () => { + const asked = []; + const { resolveSubjectPhoto, subjectFromContext } = createSubject({ + state: {}, + services: { + flights: {}, + military: {}, + vessels: { + getVesselPhoto: (id) => { + asked.push(id); + return id === 'uuid-1' ? PHOTO : null; + }, + }, + installations: {}, + }, + parts: { navigation: { isFlightLayer: () => false } }, + }); + assert.deepEqual( + resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-1' }), + PHOTO, + ); + assert.equal( + resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-2' }), + null, + ); + assert.deepEqual(asked, ['uuid-1', 'uuid-2']); + const vessel = (label, mmsi) => + subjectFromContext({ + layerId: 'ais-live-vessels', + id: 'ais-uuid-1', + label, + latitude: 38.3, + longitude: -123.05, + properties: { vesselId: 'uuid-1', mmsi }, + }); + assert.equal( + vessel('USCGC SOCKEYE', '366999709').label, + 'USCGC SOCKEYE · MMSI 366999709', + ); + assert.equal(vessel('USCGC SOCKEYE', '366999709').id, 'uuid-1'); + // A vessel named by its MMSI, or without one, is not given it twice. + assert.equal(vessel('MMSI 366999709', '366999709').label, 'MMSI 366999709'); + assert.equal(vessel('USCGC SOCKEYE', null).label, 'USCGC SOCKEYE'); +}); + +test('only the tracked flight has a flight photo: not an installation, a military aircraft or another flight', () => { const tracked = { layerId: 'flights', id: 'abc123', diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index e8bb26a8c..e60da476e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -406,17 +406,20 @@ export function createSubject({ state: layerState, services, parts, source }) { } /** - * The tracked airliner's Planespotters photo, for the Contacts panel. + * The subject's photo, for the Contacts panel: the tracked airliner's + * Planespotters photo, or the selected vessel's Open Vessel Photos photo. * - * 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. + * The flights layer looks photos up only for the plane it tracks, the + * vessel layer only for the vessel selected; 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}} + * @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string, licence?: string}} */ function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) { + if (subject?.layerId === 'ais-live-vessels') + return aisLiveVesselsLayer.getVesselPhoto?.(subject.id) ?? null; return subject?.layerId === 'flights' && tracked?.photo ? tracked.photo : null; @@ -517,10 +520,15 @@ export function createSubject({ state: layerState, services, parts, source }) { const latitude = Number(record.latitude); const longitude = Number(record.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + // A vessel reads as its name and MMSI; a vessel named by its MMSI alone + // already carries it. + const label = String(record.label || record.id); + const mmsi = String(record.properties?.mmsi || ''); return { layerId: record.layerId, id: record.properties?.vesselId || record.id, - label: record.label || record.id, + // i18n-ignore: MMSI is a radio identity code, as on the vessel card + label: mmsi && !label.includes(mmsi) ? `${label} · MMSI ${mmsi}` : label, memberNames: record.properties?.memberNames || [], position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), }; diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index bd8282e84..c6d1429dc 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -7,6 +7,7 @@ import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; import { createTracking } from './tracking.js'; import { createCards } from './cards.js'; +import { createPhotos } from './photos.js'; import { createTesting } from './testing.js'; import { createEvidence } from './evidence.js'; import { createQueries } from './queries.js'; @@ -24,6 +25,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) { parts.selection = createSelection(context); parts.tracking = createTracking(context); parts.cards = createCards(context); + parts.photos = createPhotos(context); parts.testing = createTesting(context); parts.evidence = createEvidence(context); parts.queries = createQueries(context); @@ -90,6 +92,9 @@ export function createVesselLayer({ source, services, options = {} } = {}) { buildVesselCard: parts.cards.buildVesselCard, buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, cardScreenSeparated: parts.cards.cardScreenSeparated, + /** The vessel's Open Vessel Photos photo, once its lookup answered. */ + getVesselPhoto: (id) => + parts.photos.vesselPhoto(vesselState.state.records.byId.get(id)), }); Object.defineProperty(layer, 'testing', { value: parts.testing }); return layer; diff --git a/src/layers/vessels/photos.js b/src/layers/vessels/photos.js new file mode 100644 index 000000000..6a29b19a9 --- /dev/null +++ b/src/layers/vessels/photos.js @@ -0,0 +1,56 @@ +import { MMSI, parseOpenVesselPhoto } from '../../data/openVesselPhoto.js'; + +/** A failed lookup is asked again after this long; a miss is final. */ +const PHOTO_RETRY_MS = 5 * 60 * 1000; +/** Lookups remembered per session, oldest dropped first. */ +const PHOTO_CACHE_LIMIT = 500; + +/** + * The selected vessel's photo, looked up once per MMSI when it is selected. + * The answer is read with `parseOpenVesselPhoto` whatever shape the source + * returns, so only a checked photo is ever kept. A source without + * `getEnrichment` has no photos. + */ +export function createPhotos({ vesselState }) { + /** MMSI → photo, `null` for a final miss, or `{failedAtMs}`/`{pending}`. */ + const answers = new Map(); + + function remember(mmsi, answer) { + answers.delete(mmsi); + answers.set(mmsi, answer); + if (answers.size > PHOTO_CACHE_LIMIT) + answers.delete(answers.keys().next().value); + } + + /** Ask for the record's photo unless it is known, asked or cooling down. */ + function requestVesselPhoto(record, now = Date.now()) { + const mmsi = String(record?.mmsi || ''); + const source = vesselState._source; + if (!MMSI.test(mmsi) || typeof source?.getEnrichment !== 'function') return; + const known = answers.get(mmsi); + if (known !== undefined) { + if (known?.pending || known === null || known.found) return; + if (now - known.failedAtMs < PHOTO_RETRY_MS) return; + } + remember(mmsi, { pending: true }); + Promise.resolve() + .then(() => source.getEnrichment({ kind: 'photo', id: mmsi })) + .then( + (answer) => { + const photo = parseOpenVesselPhoto(answer); + remember(mmsi, photo.found ? photo : null); + }, + () => remember(mmsi, { failedAtMs: Date.now() }), + ); + } + + /** The vessel's checked photo, or null. */ + function vesselPhoto(record) { + const answer = answers.get(String(record?.mmsi || '')); + if (!answer?.found) return null; + const { src, width, height, photographer, licence, link } = answer; + return { src, width, height, photographer, licence, link }; + } + + return { requestVesselPhoto, vesselPhoto }; +} diff --git a/src/layers/vessels/photos.test.mjs b/src/layers/vessels/photos.test.mjs new file mode 100644 index 000000000..2e1163853 --- /dev/null +++ b/src/layers/vessels/photos.test.mjs @@ -0,0 +1,87 @@ +// src/layers/vessels/photos.test.mjs — a selected vessel's photo is asked for +// once per MMSI; a miss is final and a failure is retried only after a pause. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPhotos } from './photos.js'; + +const ANSWER = Object.freeze({ + mmsi: '366999709', + photos: [ + { + urls: { + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + credit: 'Gordon Leggett', + licenceName: 'CC BY 4.0', + width: 1600, + height: 1200, + }, + ], +}); + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture(answer) { + const asked = []; + const source = { + getSnapshot() {}, + async getEnrichment(query) { + asked.push(query); + return answer(query); + }, + }; + const photos = createPhotos({ vesselState: { _source: source } }); + return { photos, asked }; +} + +test('a selected vessel is asked once and shows its checked photo', async () => { + const { photos, asked } = fixture(() => ANSWER); + const record = { id: 'v1', mmsi: '366999709' }; + photos.requestVesselPhoto(record); + photos.requestVesselPhoto(record); + await settle(); + photos.requestVesselPhoto(record); + assert.deepEqual(asked, [{ kind: 'photo', id: '366999709' }]); + assert.deepEqual(photos.vesselPhoto(record), { + src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800', + width: 800, + height: 600, + photographer: 'Gordon Leggett', + licence: 'CC BY 4.0', + link: 'https://openvesselphotos.org/vessels/mmsi/366999709', + }); +}); + +test('a vessel without a valid MMSI, or a source without lookups, is never asked', async () => { + const { photos, asked } = fixture(() => ANSWER); + photos.requestVesselPhoto({ id: 'v1', mmsi: '' }); + photos.requestVesselPhoto({ id: 'v2', mmsi: '12345' }); + await settle(); + assert.equal(asked.length, 0); + const bare = createPhotos({ vesselState: { _source: { getSnapshot() {} } } }); + bare.requestVesselPhoto({ id: 'v3', mmsi: '366999709' }); + assert.equal(bare.vesselPhoto({ mmsi: '366999709' }), null); +}); + +test('a miss is final; a failure is asked again only after the pause', async () => { + let fail = true; + const { photos, asked } = fixture(({ id }) => { + if (id === '111111111') return { mmsi: id, photos: [] }; + if (fail) throw new Error('down'); + return ANSWER; + }); + const missing = { mmsi: '111111111' }; + const flaky = { mmsi: '366999709' }; + photos.requestVesselPhoto(missing); + photos.requestVesselPhoto(flaky, 0); + await settle(); + photos.requestVesselPhoto(missing); + photos.requestVesselPhoto(flaky, Date.now() + 1000); + assert.equal(asked.length, 2); + fail = false; + photos.requestVesselPhoto(flaky, Date.now() + 10 * 60 * 1000); + await settle(); + assert.equal(asked.length, 3); + assert.equal(photos.vesselPhoto(missing), null); + assert.equal(photos.vesselPhoto(flaky).photographer, 'Gordon Leggett'); +}); diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 99f3b674a..717a69408 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -186,6 +186,7 @@ export function createSelection({ // click, not up to VISIBILITY_UPDATE_MS later. components.rendering.updateVisibility(true); components.cards.updateSelectedVesselHud(record); + components.photos.requestVesselPhoto(record); if (registerSelectedContext(record)) { selectEntityContext(record); } diff --git a/src/sources/live/index.js b/src/sources/live/index.js index 102a4262c..dbd675c67 100644 --- a/src/sources/live/index.js +++ b/src/sources/live/index.js @@ -16,4 +16,5 @@ export { createFlightSource, createMilitarySource, createVesselSource, + fetchOpenVesselPhoto, } from './standalone.js'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index b244266b0..41e858a93 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -12,6 +12,11 @@ import { } from './aircraft.js'; import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js'; +import { + MMSI, + openVesselPhotosUrl, + parseOpenVesselPhoto, +} from '../../data/openVesselPhoto.js'; const defaultFetch = (...args) => globalThis.fetch(...args); const header = (response, name) => response.headers?.get?.(name); @@ -42,6 +47,34 @@ async function planespottersPhoto(fetchImpl, hex, signal) { return parsePlanespottersPhoto(payload); } +/** + * A vessel's Open Vessel Photos photo, read and host-checked. The API is + * keyless and answers with `access-control-allow-origin: *`, so the browser + * asks it straight. An MMSI it does not know answers 404: no photo. + * @param {Function} fetchImpl fetch. + * @param {string} mmsi The vessel's nine-digit MMSI. + * @param {AbortSignal} [signal] Abort signal. + * @returns {Promise} A `parseOpenVesselPhoto` result. + */ +export async function fetchOpenVesselPhoto(fetchImpl, mmsi, signal) { + const id = String(mmsi || ''); + if (!MMSI.test(id)) + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + const timeout = AbortSignal.timeout(PHOTO_TIMEOUT_MS); + const { response, payload } = await readResponse( + fetchImpl, + openVesselPhotosUrl(id), + { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + credentials: 'omit', + }, + 'Open Vessel Photos', + ); + if (response.status === 404) return parseOpenVesselPhoto(null); + if (!response.ok) throw httpError(response, 'Open Vessel Photos'); + return parseOpenVesselPhoto(payload, id); +} + function openSkyError(response) { const error = httpError(response, 'Flights'); const mode = String( @@ -235,6 +268,11 @@ export function createVesselSource({ status: response.status, }; }, + async getEnrichment(query, { signal } = {}) { + if (query.kind !== 'photo') + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + return fetchOpenVesselPhoto(fetchImpl, query.id, signal); + }, async getTrack(reference, { signal } = {}) { const { response, payload } = await readResponse( fetchImpl,