mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
feat(layers): add shipwrecks layer (EMODnet + Wikidata)
A new Events layer maps shipwrecks with a card per wreck: - EMODnet Human Activities heritage shipwrecks (about 6,500 in European waters, CC BY 4.0), skipping rows taken from wrecksite.eu. - Wikidata shipwrecks with a Wikipedia article plus sinking events (CC0); an event folds into its ship for the date and death toll. - A same-origin /api/shipwrecks proxy refreshes once a day and serves the last snapshot as stale for up to a week. Markers are a boat icon coloured by era and sized by deaths; four era chips filter the layer. The card follows the air-accidents layout (deaths first). Text that names people is dropped; victims appear as a count only. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7d8b80db0a
commit
dcd0f2cccd
41 files changed
+2168
-73
No files matched your search
@@ -39,6 +39,8 @@ How to read this:
|
||||
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
|
||||
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
|
||||
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
|
||||
| **EMODnet Human Activities — Ship wrecks** (`ows.emodnet-humanactivities.eu/wfs`, layer `emodnet:heritageshipwrecks`) | Shipwrecks layer: about 7,000 wrecks in European waters (name, sinking year and text, vessel type, least depth and its quality, position accuracy, period, cargo, protected status). Keyless WFS GeoJSON, fetched by `/api/shipwrecks` once a day; rows sourced from wrecksite.eu are skipped and text naming people is dropped | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (EMODnet catalogue record `e965088b-a265-4517-84df-c49b156af8a7`; data originator AND-International, compiled from SHOM, National Monuments Service, Historic England and the Oxford Roman Economy Project); fetched at runtime, not stored in this repository | "Shipwrecks (Europe): EMODnet Human Activities (data originator AND-International, …), CC BY 4.0" — shown in-app |
|
||||
| **Wikidata — shipwrecks** (`query.wikidata.org/sparql`) | Shipwrecks layer: shipwreck items (Q852190) with a coordinate and at least one Wikipedia article: name, ship type, sinking date, death count, description and article link. Keyless, fetched by `/api/shipwrecks` once a day | [CC0](https://creativecommons.org/publicdomain/zero/1.0/); fetched at runtime | "Shipwrecks (worldwide): Wikidata (CC0)" — shown in-app |
|
||||
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
|
||||
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
|
||||
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
|
||||
|
||||
@@ -98,6 +98,36 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
## Shipwrecks — EMODnet and Wikidata
|
||||
|
||||
The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `9`),
|
||||
off by default. `/api/shipwrecks` downloads EMODnet Human Activities'
|
||||
`emodnet:heritageshipwrecks` WFS layer (about 7,000 wrecks in European waters
|
||||
compiled from SHOM, Ireland's National Monuments Service, Historic England and
|
||||
the Oxford Roman Economy Project; CC BY 4.0) and a Wikidata SPARQL query for
|
||||
shipwreck items with a coordinate and at least one Wikipedia article (about
|
||||
1,450 worldwide; CC0), at most once a day, and serves the last good snapshot as
|
||||
stale for up to seven days when a refresh fails. One source failing still serves
|
||||
the other. EMODnet rows the portal took from wrecksite.eu are skipped (that site
|
||||
is not openly licensed). A Wikidata wreck with the same name within 3 km of an
|
||||
EMODnet wreck is merged into one record that keeps the article link and takes
|
||||
EMODnet's year, sinking text, depth and position accuracy. Only ship columns are
|
||||
read: EMODnet's long histories (`ship_char`, `obj_desc`) are not, and any
|
||||
sinking or cargo text that names a person (owner, master, a diver who reported
|
||||
it) is dropped whole; deaths appear only as a Wikidata count.
|
||||
|
||||
The globe draws a small hull marker per wreck, ground-clamped with the depth
|
||||
test off and the horizon cull GhostMaps uses, coloured by era: before 1900
|
||||
(ancient wrecks dated only by period count here), 1900–1945, after 1945, and
|
||||
date unknown. Four row chips show or hide each era (session-only, not in the
|
||||
share link). Clicking a marker opens a `stateless` card with the name, vessel
|
||||
type, sinking date or year (or the archaeological dating), what happened, deaths,
|
||||
least depth and its quality, how well the position is known, period notes or
|
||||
the Wikidata description, cargo or finds, protected status, waters and source.
|
||||
Clicking the card opens the Wikipedia article (else the Wikidata item), the
|
||||
wreck's own web page when EMODnet lists one, else EMODnet's portal. Voice does
|
||||
not toggle this layer yet (prompt budget, as for the airspace layer).
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -4212,6 +4242,7 @@ its criteria cannot be silently ignored.
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Shipwrecks ⚓ (`shipwrecks`) | EMODnet Human Activities heritage shipwrecks (European waters, keyless WFS, CC BY 4.0) and Wikidata (notable wrecks worldwide, keyless, CC0) | `src/layers/shipwrecks/` via `src/app/layers/shipwrecks.js` | `/api/shipwrecks` | 6 h page check; server refresh once a day (stale-on-error up to 7 days) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
|
||||
@@ -1267,5 +1267,9 @@
|
||||
"src/voice/geminiTextChat.js",
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/textChat.js",
|
||||
"src/voice/textProtocol.js"
|
||||
"src/voice/textProtocol.js",
|
||||
"src/layers/shipwrecks/records.test.mjs",
|
||||
"src/layers/shipwrecks/cards.test.mjs",
|
||||
"src/layers/shipwrecks/eras.test.mjs",
|
||||
"src/data/shipwrecksProxy.test.mjs"
|
||||
]
|
||||
@@ -2939,6 +2939,12 @@
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
@@ -3690,6 +3696,12 @@
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
@@ -4751,6 +4763,12 @@
|
||||
"src/layers/ghostMaps/records.js",
|
||||
"src/layers/ghostMaps/kml.js",
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/data/geoFileImport.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -4778,6 +4796,12 @@
|
||||
"exports": ["./layers/ghostmaps/source"],
|
||||
"modules": [
|
||||
"src/layers/ghostMaps/source.js",
|
||||
"src/app/layers/shipwrecks.js",
|
||||
"src/layers/shipwrecks/index.js",
|
||||
"src/layers/shipwrecks/source.js",
|
||||
"src/layers/shipwrecks/records.js",
|
||||
"src/layers/shipwrecks/cards.js",
|
||||
"src/layers/shipwrecks/eras.js",
|
||||
"src/layers/ghostMaps/catalog.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { weatherProxy } from './weather.js';
|
||||
import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
||||
import { ghostMapsProxy } from './ghostMaps.js';
|
||||
import { shipwrecksProxy } from './shipwrecks.js';
|
||||
import { droneAirspaceProxy } from './droneAirspace.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
@@ -80,6 +81,7 @@ function localProviderPlugins({
|
||||
firePerimetersProxy(),
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
shipwrecksProxy(),
|
||||
droneAirspaceProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
EMODNET_WFS_URL,
|
||||
WIKIDATA_QUERY,
|
||||
mergeShipwreckRecords,
|
||||
normalizeEmodnetFeatures,
|
||||
normalizeWikidataResults,
|
||||
} from '../../src/layers/shipwrecks/records.js';
|
||||
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
|
||||
import { clientKey, makeRateLimiter } from './common/rate-limit.js';
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
/** EMODnet republishes the wreck layer about once a year; Wikidata daily at most. */
|
||||
const REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||
/** A failed refresh keeps serving the last snapshot this long, marked stale. */
|
||||
const STALE_MAX_MS = 7 * REFRESH_MS;
|
||||
// Only the columns the card uses; ship_char/obj_desc histories can name people
|
||||
// and are not read beyond a short vessel type.
|
||||
const EMODNET_PROPERTIES = [
|
||||
'source_id',
|
||||
'country',
|
||||
'name',
|
||||
'least_depth',
|
||||
'depth_prec',
|
||||
'loc_prec',
|
||||
'sink_context',
|
||||
'sink_yr',
|
||||
'dating',
|
||||
'period',
|
||||
'statutory',
|
||||
'ship_char',
|
||||
'obj_type',
|
||||
'artefacts',
|
||||
'source_inf',
|
||||
'website1',
|
||||
'point_info',
|
||||
'the_geom',
|
||||
].join(',');
|
||||
const EMODNET_URL = `${EMODNET_WFS_URL}&propertyName=${EMODNET_PROPERTIES}`;
|
||||
const WIKIDATA_URL =
|
||||
'https://query.wikidata.org/sparql?format=json&query=' +
|
||||
encodeURIComponent(WIKIDATA_QUERY);
|
||||
// Wikimedia asks automated clients to name themselves.
|
||||
const USER_AGENT =
|
||||
"God's Eye View shipwrecks layer (https://github.com/bilawalsidhu/gods-eye-view)";
|
||||
|
||||
/** Fixed-origin, cached EMODnet + Wikidata shipwreck snapshot for dev and preview. */
|
||||
export function shipwrecksProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let snapshot = null;
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
|
||||
|
||||
async function upstreamJson(url, maxBytes) {
|
||||
const signal = AbortSignal.timeout(90_000);
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
return readResponseJsonCapped(response, maxBytes, signal);
|
||||
}
|
||||
|
||||
async function fetchEmodnet() {
|
||||
const records = normalizeEmodnetFeatures(
|
||||
await upstreamJson(EMODNET_URL, 32 * MIB),
|
||||
);
|
||||
if (!records.length) throw new Error('invalid_emodnet_layer');
|
||||
return records;
|
||||
}
|
||||
|
||||
async function fetchWikidata() {
|
||||
return normalizeWikidataResults(await upstreamJson(WIKIDATA_URL, 16 * MIB));
|
||||
}
|
||||
|
||||
/** One source failing still serves the other; both failing is an error. */
|
||||
async function load() {
|
||||
const [emodnet, wikidata] = await Promise.allSettled([
|
||||
fetchEmodnet(),
|
||||
fetchWikidata(),
|
||||
]);
|
||||
if (emodnet.status === 'rejected' && wikidata.status === 'rejected')
|
||||
throw new Error('shipwrecks_unavailable');
|
||||
const records = mergeShipwreckRecords(
|
||||
emodnet.status === 'fulfilled' ? emodnet.value : [],
|
||||
wikidata.status === 'fulfilled' ? wikidata.value : [],
|
||||
);
|
||||
return {
|
||||
fetchedAt: now(),
|
||||
sources: {
|
||||
emodnet: emodnet.status === 'fulfilled',
|
||||
wikidata: wikidata.status === 'fulfilled',
|
||||
},
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
if (snapshot && now() - snapshot.fetchedAt < REFRESH_MS)
|
||||
return { value: snapshot, stale: false };
|
||||
try {
|
||||
const { promise } = coalesceProxyRequest(inFlight, 'snapshot', load);
|
||||
snapshot = await promise;
|
||||
return { value: snapshot, stale: false };
|
||||
} catch (error) {
|
||||
if (snapshot && now() - snapshot.fetchedAt <= STALE_MAX_MS)
|
||||
return { value: snapshot, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, stale = false) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const path = (req.url || '/').split('?')[0];
|
||||
if (path !== '/' && path !== '')
|
||||
return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||
try {
|
||||
const { value, stale } = await acquire();
|
||||
json(200, stale ? { ...value, stale: true } : value, stale);
|
||||
} catch {
|
||||
json(502, { error: 'shipwrecks_unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'shipwrecks',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/shipwrecks', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/shipwrecks', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationGhostMaps } from './layers/ghostMaps.js';
|
||||
import { createApplicationShipwrecks } from './layers/shipwrecks.js';
|
||||
import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
@@ -134,6 +135,7 @@ export function createApplicationCatalog({
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationShipwrecks({ source: sources.shipwrecks }),
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.equal(first.layers.length, 38);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.equal(catalog.layers.length, 38);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createShipwrecksLayer } from '../../layers/shipwrecks/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire the EMODnet + Wikidata shipwrecks layer into the application catalog. */
|
||||
export function createApplicationShipwrecks(options) {
|
||||
return createShipwrecksLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
shipwrecks: contract(['shipwrecks'], ['getSnapshot']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
assert.equal(actual.layers.length, 38);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -492,6 +492,14 @@ export const DATA_CREDITS = [
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
|
||||
'Shown as published; reports are S2 assessments, not verified fact.',
|
||||
},
|
||||
{
|
||||
key: 'shipwrecks',
|
||||
html:
|
||||
'Shipwrecks (Europe): <a href="https://emodnet.ec.europa.eu/en/human-activities" target="_blank" rel="noopener">EMODnet Human Activities</a> ' +
|
||||
'(data originator AND-International, compiled from SHOM, Ireland’s National Monuments Service, Historic England and the Oxford Roman Economy Project), ' +
|
||||
'licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>' +
|
||||
' · Shipwrecks (worldwide): <a href="https://www.wikidata.org/" target="_blank" rel="noopener">Wikidata</a> (CC0)',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
|
||||
@@ -691,6 +691,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
optionOwner: 'satellites',
|
||||
}),
|
||||
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'shipwrecks', token: '9', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'street-level',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 36);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,19 +243,17 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '9');
|
||||
assert.equal(nextLayerStateToken(), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '9',
|
||||
bravo: '00',
|
||||
alpha: '00',
|
||||
bravo: '01',
|
||||
}),
|
||||
'01',
|
||||
'02',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
|
||||
};
|
||||
// shipwrecks took the last single digit, 9.
|
||||
const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS };
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }),
|
||||
@@ -306,8 +304,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '9',
|
||||
next: '00',
|
||||
future: '00',
|
||||
next: '01',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -315,12 +313,12 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
future: '01',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 00/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
delete beforeLastDigit.shipwrecks;
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
@@ -340,10 +338,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00', competing: '00' },
|
||||
),
|
||||
/next free token 00/,
|
||||
/next free token 01/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
@@ -2711,6 +2709,22 @@ test('drone restrictions uses digit 8 without colliding with fire perimeters', (
|
||||
);
|
||||
});
|
||||
|
||||
test('shipwrecks uses digit 9 without colliding with drone restrictions', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=9.8'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
'drone-airspace',
|
||||
'shipwrecks',
|
||||
]);
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'shipwrecks').token,
|
||||
'9',
|
||||
);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
|
||||
decoded,
|
||||
);
|
||||
});
|
||||
|
||||
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
|
||||
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -140,6 +140,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
shipwrecks: '9',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -165,9 +166,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
|
||||
.future,
|
||||
'9',
|
||||
'00',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -185,14 +186,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '00']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
assert.equal(nextLayerStateToken(published), '01');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '00',
|
||||
newcomer: '01',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -200,15 +201,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '9',
|
||||
competing: '00',
|
||||
}),
|
||||
/next free token 00/,
|
||||
/next free token 01/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '9',
|
||||
newcomer: '00',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -216,8 +217,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 00 with 01 after PR A publishes 00', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '00']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -238,8 +239,8 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
[...reservationRows, ['pr-b', '00']],
|
||||
[{ id: 'pr-b', token: '00' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -247,10 +248,10 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[...reservationRows, ['pr-a', '00'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
{ id: 'pr-a', token: '00' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -258,16 +259,16 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
assert.equal(nextLayerStateToken(published), '01');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '00']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '00' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 00/);
|
||||
assert.match(helper.stdout, /pr-b: 01/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -275,26 +276,23 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[...reservationRows, ['pr-a', '00'], ['pr-b', '01']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
{ id: 'pr-a', token: '00' },
|
||||
{ id: 'pr-b', token: '01' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /36 published, 1 new/);
|
||||
assert.match(corrected.stdout, /37 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
]);
|
||||
// shipwrecks took the last single digit, 9.
|
||||
const allDigits = parseLayerStateTokenReservations(reservationRows);
|
||||
const beforeNine = { ...allDigits };
|
||||
delete beforeNine['retired-9'];
|
||||
delete beforeNine['shipwrecks'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeNine, {
|
||||
...beforeNine,
|
||||
@@ -340,26 +338,19 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'9'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({
|
||||
id: `qa-layer-${token}`,
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers);
|
||||
const codec = await import(pathToFileURL(fixtureModule));
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
),
|
||||
nextLayerStateToken(parseLayerStateTokenReservations(reservationRows)),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -377,7 +368,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 81);
|
||||
assert.equal(params.get('l').length, 83);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -395,7 +386,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(Object.keys(published).length, 37);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["shipwrecks", "9"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shipwrecksProxy } from '../../server/providers/shipwrecks.js';
|
||||
|
||||
const EMODNET = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'heritageshipwrecks.1',
|
||||
geometry: { type: 'Point', coordinates: [-0.92, 50.74] },
|
||||
properties: { name: 'HMS/m A1', sink_yr: '1911', country: 'UK' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function fakeFetch({ wikidataFails = false, emodnetFails = false } = {}) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
const href = String(url);
|
||||
if (href.includes('query.wikidata.org')) {
|
||||
if (wikidataFails) return new Response('', { status: 503 });
|
||||
return Response.json({ results: { bindings: [] } });
|
||||
}
|
||||
if (emodnetFails) return new Response('', { status: 503 });
|
||||
if (href.includes('ows.emodnet-humanactivities.eu'))
|
||||
return Response.json(EMODNET);
|
||||
return new Response('', { status: 404 });
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function handlerOf(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_path, fn) => (handler = fn) },
|
||||
});
|
||||
return handler;
|
||||
}
|
||||
|
||||
function request(handler, { method = 'GET', url = '/' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const res = {
|
||||
destroyed: false,
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
resolve({
|
||||
status: this.status,
|
||||
headers: this.headers,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
},
|
||||
};
|
||||
handler(
|
||||
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
|
||||
res,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('serves one cached snapshot from fixed upstreams', async () => {
|
||||
const { fetchImpl, calls } = fakeFetch();
|
||||
const handler = handlerOf(shipwrecksProxy({ fetchImpl }));
|
||||
const first = await request(handler);
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.records.length, 1);
|
||||
assert.equal(first.body.records[0].name, 'HMS/m A1');
|
||||
assert.equal(first.body.records[0].year, 1911);
|
||||
assert.deepEqual(first.body.sources, { emodnet: true, wikidata: true });
|
||||
for (const { url } of calls)
|
||||
assert.match(
|
||||
url,
|
||||
/^https:\/\/(ows\.emodnet-humanactivities\.eu\/wfs\?|query\.wikidata\.org\/sparql\?)/,
|
||||
);
|
||||
const count = calls.length;
|
||||
await request(handler);
|
||||
assert.equal(calls.length, count, 'second request is served from cache');
|
||||
});
|
||||
|
||||
test('one failing source still serves the other; both failing is a 502', async () => {
|
||||
const partial = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: fakeFetch({ wikidataFails: true }).fetchImpl,
|
||||
}),
|
||||
);
|
||||
const ok = await request(partial);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.sources, { emodnet: true, wikidata: false });
|
||||
|
||||
const down = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: fakeFetch({ wikidataFails: true, emodnetFails: true })
|
||||
.fetchImpl,
|
||||
}),
|
||||
);
|
||||
assert.equal((await request(down)).status, 502);
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last snapshot as stale', async () => {
|
||||
let clock = 0;
|
||||
let fail = false;
|
||||
const { fetchImpl: good } = fakeFetch();
|
||||
const { fetchImpl: bad } = fakeFetch({
|
||||
wikidataFails: true,
|
||||
emodnetFails: true,
|
||||
});
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: (...args) => (fail ? bad : good)(...args),
|
||||
now: () => clock,
|
||||
}),
|
||||
);
|
||||
await request(handler);
|
||||
fail = true;
|
||||
clock = 25 * 60 * 60 * 1000;
|
||||
const stale = await request(handler);
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
});
|
||||
|
||||
test('rejects other methods and routes', async () => {
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: fakeFetch().fetchImpl }),
|
||||
);
|
||||
assert.equal((await request(handler, { method: 'POST' })).status, 405);
|
||||
assert.equal((await request(handler, { url: '/x' })).status, 404);
|
||||
});
|
||||
@@ -3404,5 +3404,40 @@
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد"
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
"Shipwrecks": "حطام السفن",
|
||||
"Before 1900": "قبل 1900",
|
||||
"After 1945": "بعد 1945",
|
||||
"Date unknown": "التاريخ غير معروف",
|
||||
"Position: centre of the protected area": "الموقع: مركز المنطقة المحمية",
|
||||
"Position approximate (within about {km} km)": "الموقع تقريبي (في حدود {km} كم تقريبًا)",
|
||||
"Position surveyed (within about {m} m)": "الموقع مُسَح (في حدود {m} م تقريبًا)",
|
||||
"Vessel: {value}": "السفينة: {value}",
|
||||
"What happened: {value}": "ما حدث: {value}",
|
||||
"Depth: {m} m ({quality})": "العمق: {m} م ({quality})",
|
||||
"Depth: {m} m": "العمق: {m} م",
|
||||
"Notes: {value}": "ملاحظات: {value}",
|
||||
"Cargo or finds: {value}": "الحمولة أو المكتشفات: {value}",
|
||||
"Protected wreck": "حطام محمي",
|
||||
"Source: {value}": "المصدر: {value}",
|
||||
"Click the card to open EMODnet ↗": "انقر على البطاقة لفتح EMODnet ↗",
|
||||
"Click the card to open the source ↗": "انقر على البطاقة لفتح المصدر ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط",
|
||||
"Shipwrecks unavailable": "بيانات حطام السفن غير متاحة",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)",
|
||||
"{count} wrecks": {
|
||||
"zero": "لا حطام ({count})",
|
||||
"one": "حطام واحد ({count})",
|
||||
"two": "حطامان ({count})",
|
||||
"few": "{count} حطامات",
|
||||
"many": "{count} حطامًا",
|
||||
"other": "{count} حطام"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "غرق سفينة",
|
||||
"Fatalities: {count}": "الوفيات: {count}",
|
||||
"Survivors: {count}": "الناجون: {count}",
|
||||
"Click the card to open the article ↗": "انقر على البطاقة لفتح المقالة ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها."
|
||||
}
|
||||
@@ -3104,5 +3104,36 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
|
||||
"Shipwrecks": "Schiffswracks",
|
||||
"Before 1900": "Vor 1900",
|
||||
"After 1945": "Nach 1945",
|
||||
"Date unknown": "Datum unbekannt",
|
||||
"Position: centre of the protected area": "Position: Mitte des Schutzgebiets",
|
||||
"Position approximate (within about {km} km)": "Position ungefähr (auf etwa {km} km genau)",
|
||||
"Position surveyed (within about {m} m)": "Position vermessen (auf etwa {m} m genau)",
|
||||
"Vessel: {value}": "Schiff: {value}",
|
||||
"What happened: {value}": "Was geschah: {value}",
|
||||
"Depth: {m} m ({quality})": "Tiefe: {m} m ({quality})",
|
||||
"Depth: {m} m": "Tiefe: {m} m",
|
||||
"Notes: {value}": "Hinweise: {value}",
|
||||
"Cargo or finds: {value}": "Ladung oder Funde: {value}",
|
||||
"Protected wreck": "Geschütztes Wrack",
|
||||
"Source: {value}": "Quelle: {value}",
|
||||
"Click the card to open EMODnet ↗": "Karte anklicken, um EMODnet zu öffnen ↗",
|
||||
"Click the card to open the source ↗": "Karte anklicken, um die Quelle zu öffnen ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfügbar; nur EMODnet wird angezeigt",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfügbar; nur Wikidata wird angezeigt",
|
||||
"Shipwrecks unavailable": "Schiffswracks nicht verfügbar",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (europäische Gewässer) · Wikidata (weltweit)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} Wrack",
|
||||
"other": "{count} Wracks"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Schiffbruch",
|
||||
"Fatalities: {count}": "Todesopfer: {count}",
|
||||
"Survivors: {count}": "Überlebende: {count}",
|
||||
"Click the card to open the article ↗": "Karte anklicken, um den Artikel zu öffnen ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Klicke auf eine Markierung für ihre Karte."
|
||||
}
|
||||
@@ -3179,5 +3179,37 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
|
||||
"Shipwrecks": "Naufragios",
|
||||
"Before 1900": "Antes de 1900",
|
||||
"After 1945": "Después de 1945",
|
||||
"Date unknown": "Fecha desconocida",
|
||||
"Position: centre of the protected area": "Posición: centro de la zona protegida",
|
||||
"Position approximate (within about {km} km)": "Posición aproximada (margen de unos {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posición levantada (margen de unos {m} m)",
|
||||
"Vessel: {value}": "Embarcación: {value}",
|
||||
"What happened: {value}": "Qué ocurrió: {value}",
|
||||
"Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profundidad: {m} m",
|
||||
"Notes: {value}": "Notas: {value}",
|
||||
"Cargo or finds: {value}": "Carga o hallazgos: {value}",
|
||||
"Protected wreck": "Pecio protegido",
|
||||
"Source: {value}": "Fuente: {value}",
|
||||
"Click the card to open EMODnet ↗": "Haz clic en la ficha para abrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Haz clic en la ficha para abrir la fuente ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata",
|
||||
"Shipwrecks unavailable": "Naufragios no disponibles",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (aguas europeas) · Wikidata (todo el mundo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} naufragio",
|
||||
"many": "{count} de naufragios",
|
||||
"other": "{count} naufragios"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufragio",
|
||||
"Fatalities: {count}": "Fallecidos: {count}",
|
||||
"Survivors: {count}": "Supervivientes: {count}",
|
||||
"Click the card to open the article ↗": "Haz clic en la ficha para abrir el artículo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su ficha."
|
||||
}
|
||||
@@ -3179,5 +3179,37 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
|
||||
"Shipwrecks": "Épaves",
|
||||
"Before 1900": "Avant 1900",
|
||||
"After 1945": "Après 1945",
|
||||
"Date unknown": "Date inconnue",
|
||||
"Position: centre of the protected area": "Position : centre de la zone protégée",
|
||||
"Position approximate (within about {km} km)": "Position approximative (à environ {km} km près)",
|
||||
"Position surveyed (within about {m} m)": "Position relevée (à environ {m} m près)",
|
||||
"Vessel: {value}": "Navire : {value}",
|
||||
"What happened: {value}": "Ce qui s'est passé : {value}",
|
||||
"Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondeur : {m} m",
|
||||
"Notes: {value}": "Notes : {value}",
|
||||
"Cargo or finds: {value}": "Cargaison ou découvertes : {value}",
|
||||
"Protected wreck": "Épave protégée",
|
||||
"Source: {value}": "Source : {value}",
|
||||
"Click the card to open EMODnet ↗": "Cliquez sur la fiche pour ouvrir EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Cliquez sur la fiche pour ouvrir la source ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché",
|
||||
"Shipwrecks unavailable": "Épaves indisponibles",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} épave",
|
||||
"many": "{count} d'épaves",
|
||||
"other": "{count} épaves"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufrage",
|
||||
"Fatalities: {count}": "Morts : {count}",
|
||||
"Survivors: {count}": "Survivants : {count}",
|
||||
"Click the card to open the article ↗": "Cliquez sur la fiche pour ouvrir l'article ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le repère grossit avec le nombre de morts. Cliquez sur un repère pour voir sa fiche."
|
||||
}
|
||||
@@ -3179,5 +3179,37 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
|
||||
"Shipwrecks": "Relitti",
|
||||
"Before 1900": "Prima del 1900",
|
||||
"After 1945": "Dopo il 1945",
|
||||
"Date unknown": "Data sconosciuta",
|
||||
"Position: centre of the protected area": "Posizione: centro dell'area protetta",
|
||||
"Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)",
|
||||
"Vessel: {value}": "Imbarcazione: {value}",
|
||||
"What happened: {value}": "Cosa è successo: {value}",
|
||||
"Depth: {m} m ({quality})": "Profondità: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profondità: {m} m",
|
||||
"Notes: {value}": "Note: {value}",
|
||||
"Cargo or finds: {value}": "Carico o reperti: {value}",
|
||||
"Protected wreck": "Relitto protetto",
|
||||
"Source: {value}": "Fonte: {value}",
|
||||
"Click the card to open EMODnet ↗": "Fai clic sulla scheda per aprire EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Fai clic sulla scheda per aprire la fonte ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata non disponibile; solo EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet non disponibile; solo Wikidata",
|
||||
"Shipwrecks unavailable": "Relitti non disponibili",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (acque europee) · Wikidata (mondo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} relitto",
|
||||
"many": "{count} di relitti",
|
||||
"other": "{count} relitti"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufragio",
|
||||
"Fatalities: {count}": "Vittime: {count}",
|
||||
"Survivors: {count}": "Sopravvissuti: {count}",
|
||||
"Click the card to open the article ↗": "Fai clic sulla scheda per aprire l'articolo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda."
|
||||
}
|
||||
@@ -3029,5 +3029,35 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
|
||||
"Shipwrecks": "難破船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以降",
|
||||
"Date unknown": "日付不明",
|
||||
"Position: centre of the protected area": "位置: 保護区域の中心",
|
||||
"Position approximate (within about {km} km)": "おおよその位置(誤差約{km} km)",
|
||||
"Position surveyed (within about {m} m)": "測量された位置(誤差約{m} m)",
|
||||
"Vessel: {value}": "船種: {value}",
|
||||
"What happened: {value}": "経緯: {value}",
|
||||
"Depth: {m} m ({quality})": "水深: {m} m({quality})",
|
||||
"Depth: {m} m": "水深: {m} m",
|
||||
"Notes: {value}": "備考: {value}",
|
||||
"Cargo or finds: {value}": "積荷・遺物: {value}",
|
||||
"Protected wreck": "保護対象の難破船",
|
||||
"Source: {value}": "出典: {value}",
|
||||
"Click the card to open EMODnet ↗": "カードをクリックしてEMODnetを開く ↗",
|
||||
"Click the card to open the source ↗": "カードをクリックして出典を開く ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidataを利用できません。EMODnetのみ表示しています",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnetを利用できません。Wikidataのみ表示しています",
|
||||
"Shipwrecks unavailable": "難破船データを利用できません",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域)· Wikidata(世界)",
|
||||
"{count} wrecks": {
|
||||
"other": "難破船 {count}件"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "難破",
|
||||
"Fatalities: {count}": "死者: {count}",
|
||||
"Survivors: {count}": "生存者: {count}",
|
||||
"Click the card to open the article ↗": "カードをクリックして記事を開く ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧州の歴史的な難破船はEMODnet Human Activities、世界の著名な難破船はWikidataによります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。"
|
||||
}
|
||||
@@ -3179,5 +3179,37 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
|
||||
"Shipwrecks": "Naufrágios",
|
||||
"Before 1900": "Antes de 1900",
|
||||
"After 1945": "Depois de 1945",
|
||||
"Date unknown": "Data desconhecida",
|
||||
"Position: centre of the protected area": "Posição: centro da área protegida",
|
||||
"Position approximate (within about {km} km)": "Posição aproximada (margem de cerca de {km} km)",
|
||||
"Position surveyed (within about {m} m)": "Posição levantada (margem de cerca de {m} m)",
|
||||
"Vessel: {value}": "Embarcação: {value}",
|
||||
"What happened: {value}": "O que aconteceu: {value}",
|
||||
"Depth: {m} m ({quality})": "Profundidade: {m} m ({quality})",
|
||||
"Depth: {m} m": "Profundidade: {m} m",
|
||||
"Notes: {value}": "Notas: {value}",
|
||||
"Cargo or finds: {value}": "Carga ou achados: {value}",
|
||||
"Protected wreck": "Naufrágio protegido",
|
||||
"Source: {value}": "Fonte: {value}",
|
||||
"Click the card to open EMODnet ↗": "Clique na ficha para abrir o EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Clique na ficha para abrir a fonte ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata indisponível; mostrando só o EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet indisponível; mostrando só o Wikidata",
|
||||
"Shipwrecks unavailable": "Naufrágios indisponíveis",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (águas europeias) · Wikidata (mundo)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} naufrágio",
|
||||
"many": "{count} de naufrágios",
|
||||
"other": "{count} naufrágios"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Naufrágio",
|
||||
"Fatalities: {count}": "Mortos: {count}",
|
||||
"Survivors: {count}": "Sobreviventes: {count}",
|
||||
"Click the card to open the article ↗": "Clique na ficha para abrir o artigo ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique num marcador para ver a ficha."
|
||||
}
|
||||
@@ -3254,5 +3254,38 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
|
||||
"Shipwrecks": "Кораблекрушения",
|
||||
"Before 1900": "До 1900",
|
||||
"After 1945": "После 1945",
|
||||
"Date unknown": "Дата неизвестна",
|
||||
"Position: centre of the protected area": "Положение: центр охраняемой зоны",
|
||||
"Position approximate (within about {km} km)": "Положение приблизительное (точность около {km} км)",
|
||||
"Position surveyed (within about {m} m)": "Положение измерено (точность около {m} м)",
|
||||
"Vessel: {value}": "Судно: {value}",
|
||||
"What happened: {value}": "Что произошло: {value}",
|
||||
"Depth: {m} m ({quality})": "Глубина: {m} м ({quality})",
|
||||
"Depth: {m} m": "Глубина: {m} м",
|
||||
"Notes: {value}": "Примечания: {value}",
|
||||
"Cargo or finds: {value}": "Груз или находки: {value}",
|
||||
"Protected wreck": "Охраняемый объект",
|
||||
"Source: {value}": "Источник: {value}",
|
||||
"Click the card to open EMODnet ↗": "Нажмите на карточку, чтобы открыть EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Нажмите на карточку, чтобы открыть источник ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показан только EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet недоступен; показана только Wikidata",
|
||||
"Shipwrecks unavailable": "Данные о кораблекрушениях недоступны",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (воды Европы) · Wikidata (весь мир)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} затонувшее судно",
|
||||
"few": "{count} затонувших судна",
|
||||
"many": "{count} затонувших судов",
|
||||
"other": "{count} затонувшего судна"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Кораблекрушение",
|
||||
"Fatalities: {count}": "Погибших: {count}",
|
||||
"Survivors: {count}": "Выживших: {count}",
|
||||
"Click the card to open the article ↗": "Нажмите на карточку, чтобы открыть статью ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Исторические затонувшие суда Европы — из EMODnet Human Activities, известные кораблекрушения по всему миру — из Wikidata. Цвет показывает, когда судно затонуло; маркер растёт с числом погибших. Нажмите на маркер, чтобы открыть карточку."
|
||||
}
|
||||
@@ -3254,5 +3254,38 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
|
||||
"Shipwrecks": "Корабельні аварії",
|
||||
"Before 1900": "До 1900",
|
||||
"After 1945": "Після 1945",
|
||||
"Date unknown": "Дата невідома",
|
||||
"Position: centre of the protected area": "Положення: центр охоронної зони",
|
||||
"Position approximate (within about {km} km)": "Положення приблизне (точність близько {km} км)",
|
||||
"Position surveyed (within about {m} m)": "Положення виміряно (точність близько {m} м)",
|
||||
"Vessel: {value}": "Судно: {value}",
|
||||
"What happened: {value}": "Що сталося: {value}",
|
||||
"Depth: {m} m ({quality})": "Глибина: {m} м ({quality})",
|
||||
"Depth: {m} m": "Глибина: {m} м",
|
||||
"Notes: {value}": "Примітки: {value}",
|
||||
"Cargo or finds: {value}": "Вантаж або знахідки: {value}",
|
||||
"Protected wreck": "Охоронюваний об'єкт",
|
||||
"Source: {value}": "Джерело: {value}",
|
||||
"Click the card to open EMODnet ↗": "Натисніть картку, щоб відкрити EMODnet ↗",
|
||||
"Click the card to open the source ↗": "Натисніть картку, щоб відкрити джерело ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показано лише EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet недоступний; показано лише Wikidata",
|
||||
"Shipwrecks unavailable": "Дані про корабельні аварії недоступні",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} затонуле судно",
|
||||
"few": "{count} затонулі судна",
|
||||
"many": "{count} затонулих суден",
|
||||
"other": "{count} затонулого судна"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "Корабельна аварія",
|
||||
"Fatalities: {count}": "Загиблих: {count}",
|
||||
"Survivors: {count}": "Тих, хто вижив: {count}",
|
||||
"Click the card to open the article ↗": "Натисніть картку, щоб відкрити статтю ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі корабельні аварії світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку."
|
||||
}
|
||||
@@ -3029,5 +3029,35 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以后",
|
||||
"Date unknown": "日期不详",
|
||||
"Position: centre of the protected area": "位置:保护区中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(误差约 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "实测位置(误差约 {m} 米)",
|
||||
"Vessel: {value}": "船只:{value}",
|
||||
"What happened: {value}": "经过:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 米({quality})",
|
||||
"Depth: {m} m": "深度:{m} 米",
|
||||
"Notes: {value}": "备注:{value}",
|
||||
"Cargo or finds: {value}": "货物或出水文物:{value}",
|
||||
"Protected wreck": "受保护沉船",
|
||||
"Source: {value}": "来源:{value}",
|
||||
"Click the card to open EMODnet ↗": "点击卡片打开 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "点击卡片打开来源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 不可用;仅显示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 不可用;仅显示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船数据不可用",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧洲海域)· Wikidata(全球)",
|
||||
"{count} wrecks": {
|
||||
"other": "{count} 艘沉船"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "遇难:{count}",
|
||||
"Survivors: {count}": "幸存者:{count}",
|
||||
"Click the card to open the article ↗": "点击卡片打开文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧洲历史沉船来自 EMODnet Human Activities;全球知名沉船来自 Wikidata。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。"
|
||||
}
|
||||
@@ -3029,5 +3029,35 @@
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
|
||||
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。"
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
|
||||
"Shipwrecks": "沉船",
|
||||
"Before 1900": "1900年以前",
|
||||
"After 1945": "1945年以後",
|
||||
"Date unknown": "日期不詳",
|
||||
"Position: centre of the protected area": "位置:保護區中心",
|
||||
"Position approximate (within about {km} km)": "大致位置(誤差約 {km} 公里)",
|
||||
"Position surveyed (within about {m} m)": "實測位置(誤差約 {m} 公尺)",
|
||||
"Vessel: {value}": "船隻:{value}",
|
||||
"What happened: {value}": "經過:{value}",
|
||||
"Depth: {m} m ({quality})": "深度:{m} 公尺({quality})",
|
||||
"Depth: {m} m": "深度:{m} 公尺",
|
||||
"Notes: {value}": "備註:{value}",
|
||||
"Cargo or finds: {value}": "貨物或出水文物:{value}",
|
||||
"Protected wreck": "受保護沉船",
|
||||
"Source: {value}": "來源:{value}",
|
||||
"Click the card to open EMODnet ↗": "點擊卡片開啟 EMODnet ↗",
|
||||
"Click the card to open the source ↗": "點擊卡片開啟來源 ↗",
|
||||
"Wikidata unavailable; showing EMODnet only": "Wikidata 無法使用;僅顯示 EMODnet",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet 無法使用;僅顯示 Wikidata",
|
||||
"Shipwrecks unavailable": "沉船資料無法使用",
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(歐洲海域)· Wikidata(全球)",
|
||||
"{count} wrecks": {
|
||||
"other": "{count} 艘沉船"
|
||||
},
|
||||
"1900–1945": "1900–1945",
|
||||
"Shipwreck": "沉船",
|
||||
"Fatalities: {count}": "罹難:{count}",
|
||||
"Survivors: {count}": "倖存者:{count}",
|
||||
"Click the card to open the article ↗": "點擊卡片開啟文章 ↗",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ar": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -35,6 +36,7 @@
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -206,6 +208,7 @@
|
||||
],
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -338,6 +341,7 @@
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
@@ -523,6 +527,7 @@
|
||||
],
|
||||
"it": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
@@ -698,6 +703,7 @@
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -748,6 +754,7 @@
|
||||
],
|
||||
"pt": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -880,6 +887,7 @@
|
||||
"≥ {value} MW"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
@@ -898,6 +906,7 @@
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
@@ -912,6 +921,7 @@
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -970,6 +980,7 @@
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatDate, formatNumber, msg, t } from '../../i18n/index.js';
|
||||
import { EMODNET_PAGE_URL } from './records.js';
|
||||
|
||||
export const ERA_LABELS = Object.freeze({
|
||||
'before-1900': msg('Before 1900'),
|
||||
'1900-1945': msg('1900–1945'),
|
||||
'after-1945': msg('After 1945'),
|
||||
undated: msg('Date unknown'),
|
||||
});
|
||||
|
||||
export const ERA_COLORS = Object.freeze({
|
||||
'before-1900': '#c79a5b',
|
||||
'1900-1945': '#ff6b5e',
|
||||
'after-1945': '#4fc3f7',
|
||||
undated: '#9aa7b4',
|
||||
});
|
||||
|
||||
function formatDay(date) {
|
||||
const match = /^(-?\d{4})-(\d{2})-(\d{2})$/.exec(date || '');
|
||||
if (!match) return '';
|
||||
return formatDate(
|
||||
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])),
|
||||
{ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' },
|
||||
);
|
||||
}
|
||||
|
||||
function meters(value) {
|
||||
return formatNumber(value, {
|
||||
maximumFractionDigits: value < 10 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** How well the position is known, as one card line (or null). */
|
||||
export function positionLine(record) {
|
||||
if (record.positionNote) return t('Position: centre of the protected area');
|
||||
const accuracy = record.positionAccuracy;
|
||||
if (!Number.isFinite(accuracy)) return null;
|
||||
// 1852 m is EMODnet's "within a nautical mile" for old, reported positions.
|
||||
if (accuracy >= 1000)
|
||||
return t('Position approximate (within about {km} km)', {
|
||||
km: formatNumber(accuracy / 1000, { maximumFractionDigits: 1 }),
|
||||
});
|
||||
return t('Position surveyed (within about {m} m)', { m: meters(accuracy) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a click on the card opens: the Wikipedia (or Wikidata) page, the
|
||||
* wreck's own page when EMODnet lists one, else EMODnet's portal.
|
||||
* @param {object} record
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cardLink(record) {
|
||||
return record?.url || EMODNET_PAGE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and detail lines for one wreck card, laid out like the air-accident
|
||||
* card: deaths first, then when and where, then the ship facts. Only facts
|
||||
* about the ship: the records carry no names of people, and victims appear as
|
||||
* a count only.
|
||||
* @param {object} record A normalized shipwreck record.
|
||||
* @returns {{title: string, details: string[], link: string}}
|
||||
*/
|
||||
export function describeShipwreck(record) {
|
||||
const titleParts = [t('Shipwreck')];
|
||||
if (record.name) titleParts.push(record.name);
|
||||
const details = [];
|
||||
// Deaths first, right under the title: the fact readers look for.
|
||||
const toll = [];
|
||||
if (Number.isFinite(record.casualties))
|
||||
toll.push(
|
||||
t('Fatalities: {count}', { count: formatNumber(record.casualties) }),
|
||||
);
|
||||
if (record.survivors)
|
||||
toll.push(
|
||||
t('Survivors: {count}', { count: formatNumber(record.survivors) }),
|
||||
);
|
||||
if (toll.length) details.push(toll.join(' · '));
|
||||
const when =
|
||||
formatDay(record.date) ||
|
||||
(Number.isInteger(record.year) ? String(record.year) : '') ||
|
||||
record.dating ||
|
||||
'';
|
||||
const where = [when, record.country].filter(Boolean);
|
||||
if (where.length) details.push(where.join(' · '));
|
||||
if (record.vesselType)
|
||||
details.push(t('Vessel: {value}', { value: record.vesselType }));
|
||||
if (record.cause)
|
||||
details.push(t('What happened: {value}', { value: record.cause }));
|
||||
if (Number.isFinite(record.depth))
|
||||
details.push(
|
||||
record.depthQuality
|
||||
? t('Depth: {m} m ({quality})', {
|
||||
m: meters(record.depth),
|
||||
quality: record.depthQuality,
|
||||
})
|
||||
: t('Depth: {m} m', { m: meters(record.depth) }),
|
||||
);
|
||||
const position = positionLine(record);
|
||||
if (position) details.push(position);
|
||||
if (record.notes) details.push(t('Notes: {value}', { value: record.notes }));
|
||||
if (record.cargo)
|
||||
details.push(t('Cargo or finds: {value}', { value: record.cargo }));
|
||||
if (record.protected) details.push(t('Protected wreck'));
|
||||
const sources =
|
||||
record.source === 'wikidata'
|
||||
? record.alsoIn === 'emodnet'
|
||||
? 'Wikidata · EMODnet'
|
||||
: 'Wikidata'
|
||||
: record.originator
|
||||
? `EMODnet (${record.originator})`
|
||||
: 'EMODnet';
|
||||
details.push(t('Source: {value}', { value: sources }));
|
||||
const link = cardLink(record);
|
||||
details.push(
|
||||
link === EMODNET_PAGE_URL
|
||||
? t('Click the card to open EMODnet ↗')
|
||||
: link.includes('.wikipedia.org/')
|
||||
? t('Click the card to open the article ↗')
|
||||
: t('Click the card to open the source ↗'),
|
||||
);
|
||||
return { title: titleParts.join(' · '), details, link };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeShipwreck, positionLine } from './cards.js';
|
||||
import { EMODNET_PAGE_URL } from './records.js';
|
||||
|
||||
test('a Wikidata card shows the ship facts and links its article', () => {
|
||||
const card = describeShipwreck({
|
||||
source: 'wikidata',
|
||||
alsoIn: 'emodnet',
|
||||
name: 'HMS Havant',
|
||||
vesselType: 'destroyer',
|
||||
date: '1940-06-01',
|
||||
year: 1940,
|
||||
cause: 'Was sunk on 06/01/1940 by the German air force',
|
||||
casualties: 8,
|
||||
survivors: 120,
|
||||
depth: 14.72,
|
||||
depthQuality: 'Least depth known',
|
||||
positionAccuracy: 3.1,
|
||||
notes: '1939 G and H-class destroyer',
|
||||
country: 'France',
|
||||
url: 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)',
|
||||
});
|
||||
assert.equal(card.title, 'Shipwreck · HMS Havant');
|
||||
assert.equal(card.link, 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)');
|
||||
const text = card.details.join('\n');
|
||||
assert.equal(card.details[0], 'Fatalities: 8 · Survivors: 120');
|
||||
assert.match(card.details[1], /1940 · France$/);
|
||||
assert.match(text, /Vessel: destroyer/);
|
||||
assert.match(text, /What happened: Was sunk/);
|
||||
assert.match(text, /Depth: 15 m \(Least depth known\)/);
|
||||
assert.match(text, /Position surveyed \(within about 3\.1 m\)/);
|
||||
assert.match(text, /Source: Wikidata · EMODnet/);
|
||||
assert.match(text, /open the article/);
|
||||
});
|
||||
|
||||
test('an EMODnet card without a page links the portal', () => {
|
||||
const card = describeShipwreck({
|
||||
source: 'emodnet',
|
||||
name: null,
|
||||
year: null,
|
||||
dating: 'C1st AD',
|
||||
originator: 'The Oxford Roman Economy Project',
|
||||
protected: true,
|
||||
url: null,
|
||||
});
|
||||
assert.equal(card.title, 'Shipwreck');
|
||||
assert.equal(card.link, EMODNET_PAGE_URL);
|
||||
const text = card.details.join('\n');
|
||||
assert.equal(card.details[0], 'C1st AD');
|
||||
assert.match(text, /Protected wreck/);
|
||||
assert.match(text, /EMODnet \(The Oxford Roman Economy Project\)/);
|
||||
assert.match(text, /open EMODnet/);
|
||||
});
|
||||
|
||||
test('position lines read the accuracy', () => {
|
||||
assert.equal(positionLine({}), null);
|
||||
assert.match(positionLine({ positionAccuracy: 1852 }), /about 1\.9 km/);
|
||||
assert.match(
|
||||
positionLine({ positionNote: 'x' }),
|
||||
/centre of the protected area/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Era chips, oldest first; a wreck with no known year sits in its own chip. */
|
||||
export const SHIPWRECK_ERAS = Object.freeze([
|
||||
'before-1900',
|
||||
'1900-1945',
|
||||
'after-1945',
|
||||
'undated',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The era a wreck belongs to. Ancient wrecks dated only by period
|
||||
* ("Roman Imperial", "C1st AD") count as before 1900.
|
||||
* @param {{year?: number|null, ancient?: boolean}} record
|
||||
* @returns {string}
|
||||
*/
|
||||
export function eraOf(record) {
|
||||
const year = Number.isInteger(record?.year) ? record.year : null;
|
||||
if (year === null) return record?.ancient ? 'before-1900' : 'undated';
|
||||
if (year < 1900) return 'before-1900';
|
||||
if (year <= 1945) return '1900-1945';
|
||||
return 'after-1945';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a requested set of eras; unknown names are ignored and an empty
|
||||
* or missing request keeps the current set.
|
||||
* @param {unknown} value Array of era ids.
|
||||
* @param {Set<string>} current
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function normalizeEras(value, current) {
|
||||
if (!Array.isArray(value)) return new Set(current);
|
||||
return new Set(value.filter((era) => SHIPWRECK_ERAS.includes(era)));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SHIPWRECK_ERAS, eraOf, normalizeEras } from './eras.js';
|
||||
|
||||
test('each wreck falls in one era', () => {
|
||||
assert.equal(eraOf({ year: 1588 }), 'before-1900');
|
||||
assert.equal(eraOf({ year: null, ancient: true }), 'before-1900');
|
||||
assert.equal(eraOf({ year: 1900 }), '1900-1945');
|
||||
assert.equal(eraOf({ year: 1945 }), '1900-1945');
|
||||
assert.equal(eraOf({ year: 1946 }), 'after-1945');
|
||||
assert.equal(eraOf({ year: null }), 'undated');
|
||||
assert.equal(eraOf(null), 'undated');
|
||||
});
|
||||
|
||||
test('era requests keep known eras only', () => {
|
||||
const all = new Set(SHIPWRECK_ERAS);
|
||||
assert.deepEqual(
|
||||
[...normalizeEras(['after-1945', 'bogus'], all)],
|
||||
['after-1945'],
|
||||
);
|
||||
assert.deepEqual([...normalizeEras('after-1945', all)], SHIPWRECK_ERAS);
|
||||
assert.deepEqual([...normalizeEras([], all)], []);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, msg, t, tn } from '../../i18n/index.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { ERA_COLORS, ERA_LABELS, describeShipwreck } from './cards.js';
|
||||
import { SHIPWRECK_ERAS, eraOf, normalizeEras } from './eras.js';
|
||||
export * from './records.js';
|
||||
export * from './cards.js';
|
||||
export * from './eras.js';
|
||||
export { createShipwrecksSource } from './source.js';
|
||||
|
||||
export const SHIPWRECKS_LAYER_ID = 'shipwrecks';
|
||||
export const SHIPWRECKS_OVERLAY_SOURCE_ID = 'shipwrecks';
|
||||
const PICK_PREFIX = 'shipwreck:';
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
const MAX_CARD_LINES = 14;
|
||||
/**
|
||||
* Marker size in pixels: grows with the death toll, as on the air-accidents
|
||||
* layer, from 16 px (no deaths known) to 36 px (about a thousand or more).
|
||||
*/
|
||||
export function markerPixelSize(casualties) {
|
||||
const n = Number.isFinite(casualties) && casualties > 0 ? casualties : 0;
|
||||
return Math.round(16 + Math.min(20, Math.log2(1 + n) * 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* A round marker in the era's colour with a sinking hull and mast in the
|
||||
* middle, as an SVG data URL.
|
||||
* @param {string} color CSS colour.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function wreckMarkerImage(color) {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
|
||||
`<circle cx="18" cy="18" r="16" fill="${color}" stroke="#ffffff" stroke-width="2"/>` +
|
||||
`<path d="M8 21h20l-3.5 5h-13z" fill="#0b1420"/>` +
|
||||
`<path d="M17 8v12M17 9l6 7h-6" fill="none" stroke="#0b1420" stroke-width="2.2" stroke-linejoin="round"/>` +
|
||||
`</svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipwrecks: EMODnet's European heritage wrecks and Wikidata's notable
|
||||
* wrecks worldwide, one marker each, with a card and era chips.
|
||||
*/
|
||||
export function createShipwrecksLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
openExternal = null,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Shipwrecks require a snapshot source');
|
||||
let _viewer = null;
|
||||
let _enabled = false;
|
||||
let _dataSource = null;
|
||||
let _request = null;
|
||||
let _records = new Map();
|
||||
let _pins = [];
|
||||
let _snapshot = null;
|
||||
let _lastError = null;
|
||||
let _clickHandler = null;
|
||||
let _rowListener = null;
|
||||
let _selectedId = null;
|
||||
let _selectedCardId = null;
|
||||
let _selectedLink = null;
|
||||
let _eras = new Set(SHIPWRECK_ERAS);
|
||||
const _images = Object.fromEntries(
|
||||
SHIPWRECK_ERAS.map((era) => [era, wreckMarkerImage(ERA_COLORS[era])]),
|
||||
);
|
||||
|
||||
const render = () => _viewer?.scene?.requestRender?.();
|
||||
const notifyRow = () => _rowListener?.();
|
||||
const canSelect = () =>
|
||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||
// Markers skip the depth test so terrain does not swallow them; the
|
||||
// horizon cull hides the far side of the planet instead.
|
||||
const _horizon = createHorizonCull({
|
||||
getViewer: () => _viewer,
|
||||
items: () => _pins,
|
||||
onChange: render,
|
||||
});
|
||||
|
||||
/** Shown: its era chip is on. */
|
||||
function visible(record) {
|
||||
return Boolean(record) && _eras.has(eraOf(record));
|
||||
}
|
||||
|
||||
function applyPin(pin) {
|
||||
pin.entity.show = pin.inFront && visible(pin.record);
|
||||
}
|
||||
|
||||
/** Re-apply the era chips to every marker and the open card. */
|
||||
function applyFilter() {
|
||||
for (const pin of _pins) applyPin(pin);
|
||||
if (_selectedId && !visible(_records.get(_selectedId))) clearSelection();
|
||||
notifyRow();
|
||||
render();
|
||||
}
|
||||
|
||||
function rebuild(records) {
|
||||
_records = new Map(records.map((record) => [record.id, record]));
|
||||
_pins = [];
|
||||
if (!_dataSource) return;
|
||||
_dataSource.entities.suspendEvents();
|
||||
_dataSource.entities.removeAll();
|
||||
for (const record of records) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
|
||||
const entity = _dataSource.entities.add({
|
||||
id: `${PICK_PREFIX}${record.id}`,
|
||||
position,
|
||||
billboard: {
|
||||
image: _images[eraOf(record)],
|
||||
width: markerPixelSize(record.casualties),
|
||||
height: markerPixelSize(record.casualties),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// Bigger toll, drawn on top of smaller neighbours.
|
||||
eyeOffset: new Cesium.Cartesian3(
|
||||
0,
|
||||
0,
|
||||
-Math.min(record.casualties || 0, 5000),
|
||||
),
|
||||
},
|
||||
});
|
||||
const pin = {
|
||||
entity,
|
||||
record,
|
||||
position,
|
||||
inFront: true,
|
||||
get show() {
|
||||
return this.inFront;
|
||||
},
|
||||
set show(value) {
|
||||
this.inFront = value;
|
||||
applyPin(this);
|
||||
},
|
||||
};
|
||||
applyPin(pin);
|
||||
_pins.push(pin);
|
||||
}
|
||||
_dataSource.entities.resumeEvents();
|
||||
_horizon.update();
|
||||
if (_selectedId && !_records.has(_selectedId)) clearSelection();
|
||||
else if (_selectedId) publishCard();
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- selection card ----------------------------------------------------
|
||||
function publishCard() {
|
||||
if (!canSelect()) return;
|
||||
const record = _selectedId ? _records.get(_selectedId) : null;
|
||||
if (!visible(record)) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const { title, details, link } = describeShipwreck(record);
|
||||
_selectedLink = link && openExternal ? link : null;
|
||||
const card = {
|
||||
id: `shipwreck-card:${record.id}`,
|
||||
selected: true,
|
||||
interactive: Boolean(_selectedLink),
|
||||
title,
|
||||
details: details.slice(0, MAX_CARD_LINES),
|
||||
accent: ERA_COLORS[eraOf(record)],
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
stateless: true,
|
||||
horizonCull: true,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
};
|
||||
if (card.interactive) {
|
||||
const target = _selectedLink;
|
||||
card.accessibilityLabel = t('Open the source for {title}', { title });
|
||||
card.activate = () => {
|
||||
openExternal(target);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
_selectedCardId = card.id;
|
||||
overlayHost.setEntries(
|
||||
SHIPWRECKS_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
overlayHost.setVisible?.(SHIPWRECKS_OVERLAY_SOURCE_ID, true);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
_selectedId = null;
|
||||
_selectedCardId = null;
|
||||
_selectedLink = null;
|
||||
overlayHost?.setEntries(
|
||||
SHIPWRECKS_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
function pickedRecordId(picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const id = pickId.slice(PICK_PREFIX.length);
|
||||
return _records.has(id) ? id : null;
|
||||
}
|
||||
|
||||
function installClickHandler() {
|
||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||
picking.registerPickOwner?.(
|
||||
SHIPWRECKS_LAYER_ID,
|
||||
(pickedId) =>
|
||||
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
|
||||
);
|
||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{ sourceId: SHIPWRECKS_OVERLAY_SOURCE_ID },
|
||||
);
|
||||
if (cardHit && cardHit.entryId === _selectedCardId) {
|
||||
if (_selectedLink) openExternal(_selectedLink);
|
||||
return;
|
||||
}
|
||||
const picked = _viewer.scene.pick(click.position);
|
||||
const id = picked ? pickedRecordId(picked) : null;
|
||||
if (id) {
|
||||
_selectedId = id;
|
||||
publishCard();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(SHIPWRECKS_LAYER_ID, pickId))
|
||||
return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
clearSelection();
|
||||
render();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
_clickHandler?.destroy();
|
||||
_clickHandler = null;
|
||||
picking?.unregisterPickOwner?.(SHIPWRECKS_LAYER_ID);
|
||||
}
|
||||
|
||||
function toggleEra(era) {
|
||||
if (_eras.has(era)) _eras.delete(era);
|
||||
else _eras.add(era);
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function countOf(era) {
|
||||
let count = 0;
|
||||
for (const record of _records.values()) if (eraOf(record) === era) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: SHIPWRECKS_LAYER_ID,
|
||||
name: 'Shipwrecks',
|
||||
icon: '⚓',
|
||||
source: 'EMODnet · Wikidata',
|
||||
// The proxy refreshes once a day; this only notices a new snapshot.
|
||||
updateInterval: 6 * 60 * 60 * 1000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer) throw new Error('Shipwrecks layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource(SHIPWRECKS_LAYER_ID);
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
installClickHandler();
|
||||
if (_pins.length) _horizon.update();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_horizon.stop();
|
||||
render();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
try {
|
||||
const snapshot = await source.getSnapshot({ signal: request.signal });
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
_snapshot = snapshot;
|
||||
_lastError =
|
||||
snapshot.sources.emodnet && snapshot.sources.wikidata
|
||||
? null
|
||||
: snapshot.sources.emodnet
|
||||
? msg('Wikidata unavailable; showing EMODnet only')
|
||||
: msg('EMODnet unavailable; showing Wikidata only');
|
||||
rebuild(snapshot.records);
|
||||
notifyRow();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:Shipwrecks] Fetch error:', error);
|
||||
_lastError = error?.message || msg('Shipwrecks unavailable');
|
||||
notifyRow();
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
layer.disable();
|
||||
if (_dataSource) {
|
||||
viewer?.dataSources?.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_records = new Map();
|
||||
_pins = [];
|
||||
_snapshot = null;
|
||||
_viewer = null;
|
||||
_rowListener = null;
|
||||
},
|
||||
|
||||
/** The era filter: `eras` lists the era chips that are on. */
|
||||
getParams() {
|
||||
return { eras: SHIPWRECK_ERAS.filter((era) => _eras.has(era)) };
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
if (!('eras' in params)) return true;
|
||||
const next = normalizeEras(params.eras, _eras);
|
||||
if (next.size === _eras.size && [...next].every((era) => _eras.has(era)))
|
||||
return true;
|
||||
_eras = next;
|
||||
applyFilter();
|
||||
return true;
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
_rowListener = listener;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
return {
|
||||
chips: SHIPWRECK_ERAS.map((era) => ({
|
||||
id: era,
|
||||
label: t(ERA_LABELS[era]),
|
||||
active: _eras.has(era),
|
||||
color: ERA_COLORS[era],
|
||||
title: tn(countOf(era), '{count} wreck', '{count} wrecks', {
|
||||
count: formatNumber(countOf(era)),
|
||||
}),
|
||||
onClick: () => toggleEra(era),
|
||||
})),
|
||||
legend: [],
|
||||
info: t('EMODnet (European waters) · Wikidata (worldwide)'),
|
||||
infoTitle: t(
|
||||
'European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.',
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
let shown = 0;
|
||||
for (const record of _records.values()) if (visible(record)) shown++;
|
||||
return {
|
||||
count: shown,
|
||||
lastUpdate: _snapshot?.fetchedAt ?? null,
|
||||
stale: _snapshot?.stale === true,
|
||||
error: _lastError,
|
||||
};
|
||||
},
|
||||
|
||||
/** Loaded records as plain JSON-safe objects, for tools and QA. */
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_enabled) return [];
|
||||
const out = [];
|
||||
for (const record of _records.values()) {
|
||||
if (out.length >= maxCount) break;
|
||||
if (!visible(record)) continue;
|
||||
out.push({
|
||||
id: record.id,
|
||||
source: record.source,
|
||||
name: record.name,
|
||||
vesselType: record.vesselType,
|
||||
year: record.year,
|
||||
era: eraOf(record),
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
depth: record.depth,
|
||||
casualties: record.casualties,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// Shared by the server proxy (server/providers/shipwrecks.js) and the browser
|
||||
// layer, so it must not load the i18n catalogs: values stay data.
|
||||
|
||||
/** EMODnet Human Activities "Cultural Heritage – Ship wrecks" (CC BY 4.0). */
|
||||
export const EMODNET_WFS_URL =
|
||||
'https://ows.emodnet-humanactivities.eu/wfs?service=WFS&request=GetFeature' +
|
||||
'&version=2.0.0&typeNames=emodnet:heritageshipwrecks' +
|
||||
'&outputFormat=application/json&srsName=EPSG:4326';
|
||||
/** Landing page shown when an EMODnet wreck has no web page of its own. */
|
||||
export const EMODNET_PAGE_URL =
|
||||
'https://emodnet.ec.europa.eu/en/human-activities';
|
||||
|
||||
export const SHIPWRECK_SOURCES = Object.freeze(['emodnet', 'wikidata']);
|
||||
|
||||
/**
|
||||
* Wikidata (CC0): shipwreck items that have at least one Wikipedia article,
|
||||
* so the worldwide set stays to notable wrecks and skips bulk register
|
||||
* imports, plus sinking events ("sinking of the RMS Titanic"), which are where
|
||||
* Wikidata keeps the death toll. Dates come with their precision so a
|
||||
* month-precise date is not shown as the 1st. Only ship and event facts are
|
||||
* read: no items about people.
|
||||
*/
|
||||
export const WIKIDATA_QUERY = `SELECT ?item ?itemLabel ?itemDescription ?coord ?sunk ?sunkPrecision ?end ?deaths ?survivors ?typeLabel ?enwiki ?ptwiki ?event WHERE {
|
||||
{
|
||||
?item wdt:P31/wdt:P279* wd:Q852190 ; wdt:P625 ?coord ; wikibase:sitelinks ?links .
|
||||
FILTER(?links > 0)
|
||||
OPTIONAL { ?item p:P793 ?wreck . ?wreck ps:P793 wd:Q906512 ; pqv:P585 [ wikibase:timeValue ?sunk ; wikibase:timePrecision ?sunkPrecision ] }
|
||||
OPTIONAL { ?item wdt:P576 ?end }
|
||||
OPTIONAL { ?item wdt:P31 ?type . FILTER(?type != wd:Q852190) }
|
||||
} UNION {
|
||||
?item wdt:P31/wdt:P279* wd:Q906512 ; wdt:P625 ?coord .
|
||||
BIND("event" AS ?event)
|
||||
OPTIONAL { ?item p:P585/psv:P585 [ wikibase:timeValue ?sunk ; wikibase:timePrecision ?sunkPrecision ] }
|
||||
OPTIONAL { ?item wdt:P1561 ?survivors }
|
||||
}
|
||||
OPTIONAL { ?item wdt:P1120 ?deaths }
|
||||
OPTIONAL { ?enwiki schema:about ?item ; schema:isPartOf <https://en.wikipedia.org/> }
|
||||
OPTIONAL { ?ptwiki schema:about ?item ; schema:isPartOf <https://pt.wikipedia.org/> }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en,pt,mul". }
|
||||
}`;
|
||||
|
||||
const MAX_TEXT = 160;
|
||||
const MAX_NOTE = 260;
|
||||
/** Same name within this distance: one wreck seen by both sources. */
|
||||
const SAME_WRECK_KM = 3;
|
||||
|
||||
// Free text sometimes names people (owners, masters, divers who reported a
|
||||
// wreck). The layer shows ships, never people, so such text is dropped whole.
|
||||
const PERSON_TEXT =
|
||||
/\b(mr|mrs|ms|dr|capt|captain|master|skipper|owner|owned|crew|body|bodies|drowned|reported by|according to)\b/i;
|
||||
|
||||
/** EMODnet writes "n/a" or nothing where a value is unknown. */
|
||||
function clean(value, max = MAX_TEXT) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value
|
||||
.replace(/[\u0000-\u001f<>]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!text || /^(n\/a|null|unknown|none|-)$/i.test(text)) return null;
|
||||
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
/** Free text with no personal names in it, or null. */
|
||||
function impersonalText(value, max) {
|
||||
const text = clean(value, max);
|
||||
return text && !PERSON_TEXT.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function positiveNumber(value, max) {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) && n > 0 && n <= max ? n : null;
|
||||
}
|
||||
|
||||
function count(value) {
|
||||
const text = String(value ?? '').replace(/\.0+$/, '');
|
||||
if (!/^\d{1,6}$/.test(text)) return null;
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
/** A sinking year from "1917", "1917-1918" or "c. 1917"; null otherwise. */
|
||||
export function parseYear(value, maxYear = new Date().getUTCFullYear()) {
|
||||
const match = /(?:^|\D)(\d{3,4})(?:\D|$)/.exec(String(value ?? ''));
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
return year >= 100 && year <= maxYear ? year : null;
|
||||
}
|
||||
|
||||
function validPoint(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180 &&
|
||||
!(lat === 0 && lon === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function httpUrl(value) {
|
||||
const text = clean(value, 400);
|
||||
if (!text) return null;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:'
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize EMODnet's shipwreck GeoJSON. Records the portal took from
|
||||
* wrecksite.eu are skipped: that site's data is not openly licensed.
|
||||
* @param {object} payload WFS GeoJSON FeatureCollection.
|
||||
* @returns {object[]} Shipwreck records.
|
||||
*/
|
||||
export function normalizeEmodnetFeatures(payload) {
|
||||
const features = payload?.features;
|
||||
if (!Array.isArray(features)) return [];
|
||||
const out = [];
|
||||
const ids = new Set();
|
||||
for (const feature of features) {
|
||||
const p = feature?.properties || {};
|
||||
const coords = feature?.geometry?.coordinates;
|
||||
if (feature?.geometry?.type !== 'Point' || !Array.isArray(coords)) continue;
|
||||
const lon = Number(coords[0]);
|
||||
const lat = Number(coords[1]);
|
||||
if (!validPoint(lat, lon)) continue;
|
||||
const origin = clean(p.source_inf, 80);
|
||||
if (origin && /wrecksite/i.test(origin)) continue;
|
||||
const key = clean(String(feature.id ?? p.source_id ?? ''), 80);
|
||||
if (!key) continue;
|
||||
const id = `emodnet:${key}`;
|
||||
if (ids.has(id)) continue;
|
||||
ids.add(id);
|
||||
const year = parseYear(p.sink_yr);
|
||||
const dating = clean(p.dating, 60);
|
||||
const period = clean(p.period, 60);
|
||||
const vesselType =
|
||||
clean(p.obj_type, 60) ||
|
||||
(() => {
|
||||
// ship_char is sometimes a short type ("Trawler"), sometimes a long
|
||||
// history that can name people; only the short form is a type.
|
||||
const text = clean(p.ship_char, 40);
|
||||
return text && !/\d/.test(text) && !PERSON_TEXT.test(text)
|
||||
? text
|
||||
: null;
|
||||
})();
|
||||
out.push({
|
||||
id,
|
||||
source: 'emodnet',
|
||||
lat,
|
||||
lon,
|
||||
name: clean(p.name, 100),
|
||||
vesselType,
|
||||
year,
|
||||
date: null,
|
||||
dating: year ? null : dating,
|
||||
ancient: !year && Boolean(period || dating),
|
||||
cause: impersonalText(p.sink_context, MAX_NOTE),
|
||||
notes: period && dating ? `${period} (${dating})` : period,
|
||||
cargo: impersonalText(p.artefacts, 120),
|
||||
protected: /protected/i.test(p.statutory || ''),
|
||||
depth: positiveNumber(p.least_depth, 11000),
|
||||
depthQuality: clean(p.depth_prec, 80),
|
||||
positionAccuracy: positiveNumber(p.loc_prec, 100000),
|
||||
positionNote: /centroid/i.test(p.point_info || '')
|
||||
? 'Centre of the protected area'
|
||||
: null,
|
||||
casualties: null,
|
||||
survivors: null,
|
||||
country: clean(p.country, 60),
|
||||
originator: origin,
|
||||
url: httpUrl(p.website1),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wikiUrl(value) {
|
||||
const url = httpUrl(value);
|
||||
return url && /^https:\/\/[a-z]{2,3}\.wikipedia\.org\/wiki\//.test(url)
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Wikidata time as a day ("1912-04-15") when it is day-precise, else as a
|
||||
* year only: a month- or year-precise value reads back as the 1st.
|
||||
* @returns {{date: string|null, year: number|null}}
|
||||
*/
|
||||
function wikidataTime(value, precision) {
|
||||
const match = /^(-?\d{4})-(\d{2})-(\d{2})T/.exec(value || '');
|
||||
if (!match) return { date: null, year: null };
|
||||
const year = parseYear(match[1]);
|
||||
const p = Number(precision);
|
||||
// 11 = day; 9 = year; 10 = month. A missing precision (P576) counts as a day.
|
||||
if (!Number.isFinite(p) || p >= 11)
|
||||
return { date: `${match[1]}-${match[2]}-${match[3]}`, year };
|
||||
return { date: null, year: p >= 9 ? year : null };
|
||||
}
|
||||
|
||||
/** "sinking of the RMS Titanic" → "Sinking of the RMS Titanic". */
|
||||
function sentenceCase(text) {
|
||||
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Wikidata SPARQL results to shipwreck records (one per item).
|
||||
* Sinking events are folded into the ship they sank when both are listed.
|
||||
* @param {object} payload SPARQL JSON results.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function normalizeWikidataResults(payload) {
|
||||
const bindings = payload?.results?.bindings;
|
||||
if (!Array.isArray(bindings)) return [];
|
||||
const byItem = new Map();
|
||||
for (const binding of bindings) {
|
||||
const value = (key) => binding?.[key]?.value;
|
||||
const qid = /\/entity\/(Q\d{1,12})$/.exec(value('item') || '')?.[1];
|
||||
if (!qid) continue;
|
||||
const sunk = wikidataTime(value('sunk'), value('sunkPrecision'));
|
||||
const ended = wikidataTime(value('end'), null);
|
||||
const known = byItem.get(qid);
|
||||
if (known) {
|
||||
// Extra rows only add what the first one lacked.
|
||||
known.vesselType ||= clean(value('typeLabel'), 60);
|
||||
if (!known.date && !known.year) {
|
||||
known.date = sunk.date || ended.date;
|
||||
known.year = sunk.year ?? ended.year;
|
||||
}
|
||||
known.casualties ??= count(value('deaths'));
|
||||
known.survivors ??= count(value('survivors'));
|
||||
continue;
|
||||
}
|
||||
const point = /^Point\(([-\d.eE]+) ([-\d.eE]+)\)$/.exec(
|
||||
value('coord') || '',
|
||||
);
|
||||
const lon = point ? Number(point[1]) : NaN;
|
||||
const lat = point ? Number(point[2]) : NaN;
|
||||
if (!validPoint(lat, lon)) continue;
|
||||
const name = clean(value('itemLabel'), 100);
|
||||
const event = value('event') === 'event';
|
||||
const when = sunk.date || sunk.year ? sunk : ended;
|
||||
byItem.set(qid, {
|
||||
id: `wikidata:${qid}`,
|
||||
source: 'wikidata',
|
||||
event,
|
||||
lat,
|
||||
lon,
|
||||
// An unlabelled item reads back as its own Q-id; that is no name.
|
||||
name: name && name !== qid ? (event ? sentenceCase(name) : name) : null,
|
||||
vesselType: event ? null : clean(value('typeLabel'), 60),
|
||||
year: when.year,
|
||||
date: when.date,
|
||||
dating: null,
|
||||
ancient: false,
|
||||
cause: null,
|
||||
notes: impersonalText(value('itemDescription'), MAX_TEXT),
|
||||
cargo: null,
|
||||
protected: false,
|
||||
depth: null,
|
||||
depthQuality: null,
|
||||
positionAccuracy: null,
|
||||
positionNote: null,
|
||||
casualties: count(value('deaths')),
|
||||
survivors: count(value('survivors')),
|
||||
country: null,
|
||||
originator: null,
|
||||
url:
|
||||
wikiUrl(value('enwiki')) ||
|
||||
wikiUrl(value('ptwiki')) ||
|
||||
`https://www.wikidata.org/wiki/${qid}`,
|
||||
});
|
||||
}
|
||||
return foldSinkingEvents([...byItem.values()]);
|
||||
}
|
||||
|
||||
/** An event this far from a listed ship whose name it contains is that ship's sinking. */
|
||||
const SAME_SINKING_KM = 50;
|
||||
|
||||
/**
|
||||
* "Sinking of the RMS Titanic" near the RMS Titanic wreck is one marker: the
|
||||
* ship keeps its article and takes the event's date, deaths and survivors.
|
||||
*/
|
||||
function foldSinkingEvents(records) {
|
||||
// Wikidata sometimes has both the ship and its wreck as items at one spot
|
||||
// ("Titanic", "wreck of the RMS Titanic"): keep the ship, one marker.
|
||||
const isWreckItem = (record) => (/^wreck\b/i.test(record.name || '') ? 1 : 0);
|
||||
const ships = [];
|
||||
const candidates = records
|
||||
.filter((record) => !record.event)
|
||||
.sort((a, b) => isWreckItem(a) - isWreckItem(b));
|
||||
for (const record of candidates) {
|
||||
const key = nameKey(record.name);
|
||||
const twin =
|
||||
key &&
|
||||
ships.find(
|
||||
(ship) =>
|
||||
nameKey(ship.name) === key &&
|
||||
distanceKm(ship, record) <= SAME_WRECK_KM,
|
||||
);
|
||||
if (!twin) {
|
||||
ships.push(record);
|
||||
continue;
|
||||
}
|
||||
twin.vesselType ||= record.vesselType;
|
||||
if (!twin.date && !twin.year) {
|
||||
twin.date = record.date;
|
||||
twin.year = record.year;
|
||||
}
|
||||
twin.casualties ??= record.casualties;
|
||||
twin.survivors ??= record.survivors;
|
||||
}
|
||||
const out = [...ships];
|
||||
for (const event of records.filter((record) => record.event)) {
|
||||
const eventKey = ` ${nameKey(event.name)} `;
|
||||
const ship = ships.find((candidate) => {
|
||||
const key = nameKey(candidate.name);
|
||||
return (
|
||||
key.length >= 3 &&
|
||||
eventKey.includes(` ${key} `) &&
|
||||
distanceKm(candidate, event) <= SAME_SINKING_KM
|
||||
);
|
||||
});
|
||||
if (!ship) {
|
||||
out.push(event);
|
||||
continue;
|
||||
}
|
||||
if (!ship.date && !ship.year) {
|
||||
ship.date = event.date;
|
||||
ship.year = event.year;
|
||||
} else if (!ship.date && event.date && event.year === ship.year) {
|
||||
ship.date = event.date;
|
||||
}
|
||||
ship.casualties ??= event.casualties;
|
||||
ship.survivors ??= event.survivors;
|
||||
}
|
||||
for (const record of out) delete record.event;
|
||||
return out;
|
||||
}
|
||||
|
||||
function nameKey(name) {
|
||||
return String(name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/\([^)]*\)/g, ' ')
|
||||
.replace(/\b(wreck|sinking|loss|disaster|of)\b/g, ' ')
|
||||
.replace(
|
||||
/\b(hms|hmt|hmhs|hmas|sms|ss|ms|mv|rms|uss|hm|s\/s|m\/v|the)\b/g,
|
||||
' ',
|
||||
)
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function distanceKm(a, b) {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (b.lat - a.lat) * rad;
|
||||
const dLon = (b.lon - a.lon) * rad;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2;
|
||||
return 12742 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* One list from both sources. A wreck in both (same name, a few km apart)
|
||||
* keeps the Wikidata record, which links an article, and takes EMODnet's
|
||||
* survey facts (depth, sinking text, year) that Wikidata lacks.
|
||||
*/
|
||||
export function mergeShipwreckRecords(emodnet = [], wikidata = []) {
|
||||
const byName = new Map();
|
||||
for (const record of wikidata) {
|
||||
const key = nameKey(record.name);
|
||||
if (!key) continue;
|
||||
if (!byName.has(key)) byName.set(key, []);
|
||||
byName.get(key).push(record);
|
||||
}
|
||||
const kept = [];
|
||||
for (const record of emodnet) {
|
||||
const twin = (byName.get(nameKey(record.name)) || []).find(
|
||||
(candidate) => distanceKm(candidate, record) <= SAME_WRECK_KM,
|
||||
);
|
||||
if (!twin) {
|
||||
kept.push(record);
|
||||
continue;
|
||||
}
|
||||
twin.year ??= record.year;
|
||||
twin.vesselType ||= record.vesselType;
|
||||
twin.cause ||= record.cause;
|
||||
twin.depth ??= record.depth;
|
||||
twin.depthQuality ||= record.depthQuality;
|
||||
twin.positionAccuracy ??= record.positionAccuracy;
|
||||
twin.country ||= record.country;
|
||||
twin.protected ||= record.protected;
|
||||
twin.alsoIn = 'emodnet';
|
||||
}
|
||||
return [...wikidata, ...kept];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the proxy's snapshot in the browser.
|
||||
* @returns {object[]|null} Records, or null when the payload is malformed.
|
||||
*/
|
||||
export function normalizeShipwreckSnapshot(payload) {
|
||||
if (!Array.isArray(payload?.records)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const record of payload.records) {
|
||||
if (
|
||||
!record ||
|
||||
typeof record.id !== 'string' ||
|
||||
ids.has(record.id) ||
|
||||
!SHIPWRECK_SOURCES.includes(record.source) ||
|
||||
!validPoint(record.lat, record.lon)
|
||||
)
|
||||
continue;
|
||||
ids.add(record.id);
|
||||
rows.push({
|
||||
...record,
|
||||
year: Number.isInteger(record.year) ? record.year : null,
|
||||
url: httpUrl(record.url),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mergeShipwreckRecords,
|
||||
normalizeEmodnetFeatures,
|
||||
normalizeShipwreckSnapshot,
|
||||
normalizeWikidataResults,
|
||||
parseYear,
|
||||
} from './records.js';
|
||||
|
||||
function feature(id, properties, coordinates = [-4.35, 54.13]) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
test('EMODnet wrecks keep ship facts and drop text that names people', () => {
|
||||
const [wreck, roman] = normalizeEmodnetFeatures({
|
||||
features: [
|
||||
feature('w.1', {
|
||||
name: 'MAINA-NUI',
|
||||
sink_yr: '1987',
|
||||
sink_context: 'Sank on 07/02/1987',
|
||||
obj_type: 'Pusher or tug',
|
||||
least_depth: 12.5,
|
||||
depth_prec: 'Least depth known',
|
||||
loc_prec: 50,
|
||||
country: 'France',
|
||||
source_inf: 'SHOM',
|
||||
statutory: 'n/a',
|
||||
website1: 'n/a',
|
||||
}),
|
||||
feature('w.2', {
|
||||
name: 'Lerici',
|
||||
sink_yr: '',
|
||||
dating: 'C1st-2nd AD',
|
||||
period: 'Roman Imperial',
|
||||
artefacts: 'Marble, Columns',
|
||||
obj_type: 'n/a',
|
||||
ship_char: 'Owned by J. Smith, master was A. Jones',
|
||||
sink_context: 'Wrecked here, reported by Mr Smith',
|
||||
}),
|
||||
feature('w.3', { name: 'Copied', source_inf: 'www.wrecksite.eu' }),
|
||||
feature('w.4', { name: 'Null island' }, [0, 0]),
|
||||
],
|
||||
});
|
||||
assert.equal(wreck.id, 'emodnet:w.1');
|
||||
assert.equal(wreck.year, 1987);
|
||||
assert.equal(wreck.vesselType, 'Pusher or tug');
|
||||
assert.equal(wreck.cause, 'Sank on 07/02/1987');
|
||||
assert.equal(wreck.depth, 12.5);
|
||||
assert.equal(wreck.positionAccuracy, 50);
|
||||
assert.equal(wreck.url, null);
|
||||
assert.equal(roman.year, null);
|
||||
assert.equal(roman.ancient, true);
|
||||
assert.equal(roman.dating, 'C1st-2nd AD');
|
||||
assert.equal(roman.notes, 'Roman Imperial (C1st-2nd AD)');
|
||||
assert.equal(roman.cargo, 'Marble, Columns');
|
||||
assert.equal(roman.vesselType, null, 'a history naming people is no type');
|
||||
assert.equal(roman.cause, null, 'text naming a person is dropped');
|
||||
});
|
||||
|
||||
test('Wikidata rows fold into one record per item', () => {
|
||||
const item = 'http://www.wikidata.org/entity/Q1471525';
|
||||
const records = normalizeWikidataResults({
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: { value: item },
|
||||
itemLabel: { value: 'HMS Havant' },
|
||||
itemDescription: { value: '1939 G and H-class destroyer' },
|
||||
coord: { value: 'Point(2.26 51.13)' },
|
||||
sunk: { value: '1940-06-01T00:00:00Z' },
|
||||
enwiki: { value: 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)' },
|
||||
},
|
||||
{
|
||||
item: { value: item },
|
||||
typeLabel: { value: 'destroyer' },
|
||||
deaths: { value: '8' },
|
||||
coord: { value: 'Point(2.26 51.13)' },
|
||||
},
|
||||
{
|
||||
item: { value: 'http://www.wikidata.org/entity/Q9' },
|
||||
itemLabel: { value: 'Q9' },
|
||||
coord: { value: 'Point(10 10)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(records.length, 2);
|
||||
const [havant, unnamed] = records;
|
||||
assert.equal(havant.date, '1940-06-01');
|
||||
assert.equal(havant.year, 1940);
|
||||
assert.equal(havant.vesselType, 'destroyer');
|
||||
assert.equal(havant.casualties, 8);
|
||||
assert.equal(havant.url, 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)');
|
||||
assert.equal(unnamed.name, null);
|
||||
assert.equal(unnamed.url, 'https://www.wikidata.org/wiki/Q9');
|
||||
});
|
||||
|
||||
test('a wreck in both sources keeps Wikidata and takes EMODnet survey facts', () => {
|
||||
const wikidata = [
|
||||
{
|
||||
id: 'wikidata:Q1',
|
||||
source: 'wikidata',
|
||||
name: 'HMS Havant',
|
||||
lat: 51.13,
|
||||
lon: 2.26,
|
||||
year: null,
|
||||
depth: null,
|
||||
},
|
||||
];
|
||||
const emodnet = [
|
||||
{
|
||||
id: 'emodnet:a',
|
||||
source: 'emodnet',
|
||||
name: 'Havant (HMS)',
|
||||
lat: 51.131,
|
||||
lon: 2.262,
|
||||
year: 1940,
|
||||
depth: 14.7,
|
||||
},
|
||||
{
|
||||
id: 'emodnet:b',
|
||||
source: 'emodnet',
|
||||
name: 'HMS Havant',
|
||||
lat: 45,
|
||||
lon: -10,
|
||||
year: 1950,
|
||||
},
|
||||
];
|
||||
const merged = mergeShipwreckRecords(emodnet, wikidata);
|
||||
assert.deepEqual(
|
||||
merged.map((record) => record.id),
|
||||
['wikidata:Q1', 'emodnet:b'],
|
||||
);
|
||||
assert.equal(merged[0].year, 1940);
|
||||
assert.equal(merged[0].depth, 14.7);
|
||||
assert.equal(merged[0].alsoIn, 'emodnet');
|
||||
});
|
||||
|
||||
test('years and snapshots are validated', () => {
|
||||
assert.equal(parseYear('1917-1918'), 1917);
|
||||
assert.equal(parseYear('c. 1588'), 1588);
|
||||
assert.equal(parseYear(''), null);
|
||||
assert.equal(parseYear('3000'), null);
|
||||
assert.equal(normalizeShipwreckSnapshot({}), null);
|
||||
const rows = normalizeShipwreckSnapshot({
|
||||
records: [
|
||||
{ id: 'a', source: 'emodnet', lat: 1, lon: 2, url: 'javascript:x' },
|
||||
{ id: 'a', source: 'emodnet', lat: 1, lon: 2 },
|
||||
{ id: 'b', source: 'other', lat: 1, lon: 2 },
|
||||
{ id: 'c', source: 'wikidata', lat: 91, lon: 2 },
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].url, null);
|
||||
});
|
||||
|
||||
test('a sinking event folds into its ship; month-precise dates keep the year only', () => {
|
||||
const entity = (q) => ({ value: `http://www.wikidata.org/entity/${q}` });
|
||||
const records = normalizeWikidataResults({
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: entity('Q25173'),
|
||||
itemLabel: { value: 'Titanic' },
|
||||
coord: { value: 'Point(-49.9469 41.7325)' },
|
||||
},
|
||||
{
|
||||
item: entity('Q3018259'),
|
||||
itemLabel: { value: 'wreck of the RMS Titanic' },
|
||||
coord: { value: 'Point(-49.9469 41.7325)' },
|
||||
},
|
||||
{
|
||||
item: entity('Q2577588'),
|
||||
itemLabel: { value: 'sinking of the RMS Titanic' },
|
||||
coord: { value: 'Point(-49.9458 41.7319)' },
|
||||
event: { value: 'event' },
|
||||
sunk: { value: '1912-04-01T00:00:00Z' },
|
||||
sunkPrecision: { value: '10' },
|
||||
deaths: { value: '1501' },
|
||||
survivors: { value: '712' },
|
||||
},
|
||||
{
|
||||
item: entity('Q1245004'),
|
||||
itemLabel: { value: 'Costa Concordia disaster' },
|
||||
coord: { value: 'Point(10.92 42.37)' },
|
||||
event: { value: 'event' },
|
||||
sunk: { value: '2012-01-13T00:00:00Z' },
|
||||
sunkPrecision: { value: '11' },
|
||||
deaths: { value: '32' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
records.map((record) => record.id),
|
||||
['wikidata:Q25173', 'wikidata:Q1245004'],
|
||||
);
|
||||
const [titanic, concordia] = records;
|
||||
assert.equal(titanic.date, null);
|
||||
assert.equal(titanic.year, 1912);
|
||||
assert.equal(titanic.casualties, 1501);
|
||||
assert.equal(titanic.survivors, 712);
|
||||
assert.equal(concordia.name, 'Costa Concordia disaster');
|
||||
assert.equal(concordia.date, '2012-01-13');
|
||||
assert.equal('event' in concordia, false);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { normalizeShipwreckSnapshot } from './records.js';
|
||||
|
||||
/** Same-origin source for the proxy's EMODnet + Wikidata shipwreck snapshot. */
|
||||
export function createShipwrecksSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/shipwrecks', { signal });
|
||||
if (!response.ok)
|
||||
throw new Error(`Shipwrecks unavailable (HTTP ${response.status})`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
16 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const records = normalizeShipwreckSnapshot(payload);
|
||||
if (!records) throw new Error('Malformed shipwrecks response');
|
||||
return {
|
||||
records,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
: null,
|
||||
stale: payload.stale === true,
|
||||
sources: {
|
||||
emodnet: payload.sources?.emodnet !== false,
|
||||
wikidata: payload.sources?.wikidata !== false,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { createCycloneSource } from '../layers/cyclones/source.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
import { createFirmsSource } from '../layers/firms/source.js';
|
||||
import { createGhostMapsSource } from '../layers/ghostMaps/source.js';
|
||||
import { createShipwrecksSource } from '../layers/shipwrecks/source.js';
|
||||
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
|
||||
import { createReferenceSources } from '../sources/reference.js';
|
||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||
@@ -60,6 +61,7 @@ export function createStandaloneLayerSources(
|
||||
alpr: createAlprTileSource(),
|
||||
firms: createFirmsSource(),
|
||||
's2-ghostmaps': createGhostMapsSource(),
|
||||
shipwrecks: createShipwrecksSource(),
|
||||
wind: createWindSource(),
|
||||
weather: createWeatherSource(),
|
||||
cyclones: createCycloneSource(),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
||||
'gnss-integrity': msg('GNSS Integrity'),
|
||||
'drone-airspace': msg('Prohibited & Restricted Airspace'),
|
||||
's2-ghostmaps': msg('S2 GhostMaps'),
|
||||
shipwrecks: msg('Shipwrecks'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
|
||||
@@ -78,6 +78,7 @@ const PANEL_GROUPS = [
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'shipwrecks',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -457,6 +457,8 @@ export const VOICE_OFF_LAYERS = Object.freeze({
|
||||
// query for this layer wait until there is room.
|
||||
'drone-airspace':
|
||||
'informational airspace layer; no room in the prompt budget',
|
||||
// Same budget: a historical wreck archive waits for room too.
|
||||
shipwrecks: 'historical shipwreck archive; no room in the prompt budget',
|
||||
});
|
||||
|
||||
const TYPE_NAMES = Object.freeze({
|
||||
|
||||
Reference in new issue
Block a user