feat(vessels): a selected vessel shows its Open Vessel Photos photo and MMSI

Selecting a vessel asks Open Vessel Photos (a free, keyless API) for its
photo by MMSI, once per MMSI. The CONTACTS view shows it under the vessel's
name and MMSI with the photographer, the photo's open licence and a link to
the vessel's page; only images on Open Vessel Photos' own host are shown, and
an answer about another MMSI is no photo. The vessel source exposes the lookup
as getEnrichment({kind: 'photo'}) and fetchOpenVesselPhoto for applications
with their own vessel source.
This commit is contained in:
Sameh Khamis committed 2026-11-12 20:10:30 -08:00
1 parent c02ede3bb4
commit 347e77d1f6
31 files changed
+591 -19

No files matched your search

+7
View File
@@ -141,6 +141,13 @@ export const DATA_CREDITS = [
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
'· each photo is © its photographer, credited beside it',
},
{
key: 'openvesselphotos',
html:
'Vessel photos: ' +
'<a href="https://openvesselphotos.org" target="_blank" rel="noopener">Open Vessel Photos</a> ' +
"· each photo keeps its author's open licence, credited beside it",
},
{
key: 'aisstream',
html:
+7
View File
@@ -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.
+135
View File
@@ -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;
}
+120
View File
@@ -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://[email protected]/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,
});
});
+2
View File
@@ -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": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "שבוע/שנ׳",
+2
View File
@@ -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",
+2
View File
@@ -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": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
+2
View File
@@ -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",
+2
View File
@@ -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": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
+2
View File
@@ -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",
+2
View File
@@ -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": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
+2
View File
@@ -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": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
+2
View File
@@ -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": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
+1
View File
@@ -625,6 +625,7 @@
"▲ {frp} MW",
"SATELLITE",
"Photo © {photographer}",
"Photo · {licence}",
"PACK",
"{count} packs",
"Orthophoto",
+17 -5
View File
@@ -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),
};
}
+46 -1
View File
@@ -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',
+14 -6
View File
@@ -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),
};
+5
View File
@@ -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;
+56
View File
@@ -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 };
}
+87
View File
@@ -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');
});
+1
View File
@@ -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);
}
+1
View File
@@ -16,4 +16,5 @@ export {
createFlightSource,
createMilitarySource,
createVesselSource,
fetchOpenVesselPhoto,
} from './standalone.js';
+38
View File
@@ -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<object>} 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,