mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Merge pull request #1583 from IMBrandao/feat/shipwrecks
feat(layers): add shipwrecks layer (EMODnet + Wikidata)
This commit is contained in:
52 files changed
+3433
-22
No files matched your search
@@ -44,6 +44,8 @@ How to read this:
|
||||
| **GDELT 2.0 events and GKG** (`data.gdeltproject.org/gdeltv2/`; no key; used only without a news server) | News layer, Conflicts: the last 24 h of GDELT's 15-minute event exports (`*.export.CSV.zip`, about 100 KB each), read server-side as they appear. Physical violence only (CAMEO roots 18–20); one report per story, copies of one agency story merged; placed at GDELT's location for it (a town the article's words name beats a bare country). Titled by the article's page title from the GKG export of the same quarter hour (`*.gkg.csv.zip`, about 6 MB each) for the newest 3 h, else by the words of the article's address. GDELT's actor coding is never shown. Categories, relevance and severity by bundled rules | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic, commercial and governmental use, with citation and a link to the GDELT Project. Coded events, page titles and article links only; linked articles keep their publishers' terms | "GDELT Project" with a link to gdeltproject.org in the Data attribution popover; "via GDELT Project", the publisher's domain and the article link on every card |
|
||||
| **Liveuamap** (`a.liveuamap.com/api`, `LIVEUAMAP_API_KEY`, optional, paid; used only without a news server) | News layer, Conflicts, beside GDELT: Liveuamap's worldwide mapped items of the last 24 h (`a=mpts`), read server-side every 5 minutes; categories and severity are read from the headline by bundled rules | Liveuamap API terms of the operator's own subscription. Headlines, places and links only; item pictures and clips are hotlinked from Liveuamap, never stored | "Liveuamap" with a link in the Data attribution popover once its items show; "Liveuamap" and the item's Liveuamap link on every card |
|
||||
| **World News API** (`api.worldnewsapi.com/search-news`, `WORLDNEWSAPI_KEY`, optional; used only without a news server) | News layer, Local: the last 24 h of news by the source country at the view centre (country from the bundled Natural Earth admin-0 pack; none in a disputed area), read server-side once per country every 30 minutes, about 1.5 points a read, kept in memory for at most 1 hour and then deleted (a read that fails is served from that copy only within the hour); at most `WORLDNEWSAPI_DAILY_READS` reads a day (default 30), after which Local says the daily limit is reached. Each story keeps World News API's own category (politics, business, else other) with no severity of its own; sport, entertainment, lifestyle and travel left out | [World News API terms](https://worldnewsapi.com/terms/) of the operator's own plan. The terms allow caching only with World News API's prior written permission, and then for at most 1 hour: getting that permission before setting the key is the operator's responsibility. Titles and links only; article text and summaries are not kept or shown; pictures are hotlinked from the publisher | The "World News API" link to worldnewsapi.com, always in the Data attribution popover (a credit; the terms require a backlink only of invited free plans); "via World News API", the publisher's domain and the article link on every card |
|
||||
| **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 free text that refers to a person by a title or role is dropped by a keyword filter (a bare name can still pass) | [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, plus sinking events (Q906512, where the death toll lives): name, ship type, sinking date with its precision, deaths and survivors (counts only), 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" |
|
||||
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- New Shipwrecks layer (Events): about 8,000 wrecks from EMODnet (CC BY 4.0)
|
||||
and Wikidata (CC0). Each has a card with deaths, date, vessel, cause, depth,
|
||||
position quality and a source link. Markers grow with the death toll, and
|
||||
a start and end year picker filters by period (IMBrandao, #1583).
|
||||
@@ -167,6 +167,49 @@ 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 `01`),
|
||||
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). A source that answered well is asked again after 24 hours;
|
||||
one that failed is asked again after 15 minutes. Each source keeps its own last
|
||||
good records: a source that fails, answers with nothing usable, or answers with
|
||||
less than half of its last good set keeps serving its earlier records as stale
|
||||
for up to seven days, and never removes the other source's wrecks. When the
|
||||
page cannot refresh, the wrecks it shows are marked stale, and it removes them
|
||||
once even the newest source is past seven days, counted from the ages the
|
||||
proxy reported (so the page and server clocks are never compared; a wreck
|
||||
both sources list is one record, so nothing is removed per source). 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. Sinking, cargo and description text that
|
||||
refers to a person by a title or role (Mr, captain, master, owner, crew,
|
||||
"reported by", …) is dropped whole by a keyword filter; a bare name with no
|
||||
such word can still pass. Deaths appear only as a Wikidata count.
|
||||
|
||||
The globe draws a round marker with a hull and mast per wreck, all in one
|
||||
billboard collection, ground-clamped with the depth test off and the horizon
|
||||
cull GhostMaps uses, sized by deaths (a bigger toll draws on top) and coloured
|
||||
by era (a legend under the row): before 1900 (ancient wrecks dated only by
|
||||
period count here), 1900–1945, after 1945, and date unknown. A period filter
|
||||
under the row picks a start year and an end year side by side (two pickers
|
||||
over the years that have a wreck; a reversed pair is stored swapped); undated
|
||||
wrecks show only when every year is selected. The filter is session-only (not
|
||||
in the share link). Clicking a marker opens a `stateless` card: deaths and
|
||||
survivors first, then the sinking date or year (or the archaeological dating)
|
||||
and waters, vessel type, what happened, least depth and its quality, how well
|
||||
the position is known, period notes or the Wikidata description, cargo or
|
||||
finds, protected status 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).
|
||||
|
||||
## Wildlife Sightings — iNaturalist and Acartia
|
||||
|
||||
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
|
||||
@@ -4805,6 +4848,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) |
|
||||
| News ◆ (`news`) | The last 24 h of reports in two sections, Conflicts and Local, with hotspots, outlines and disputed areas: from a news server (`GEV_NEWS_ORIGIN`), else Core's own reading of GDELT 2.0 events (no key), Liveuamap (`LIVEUAMAP_API_KEY`) and World News API (`WORLDNEWSAPI_KEY`) | `src/layers/news/` via `src/app/layers/news.js`; panel `src/ui/newsPanel.js`, `src/ui/news/`; server `server/providers/news.js`, `server/providers/news/` | `/api/news/hotspots`, `/api/news/feed`, `/api/news/outline` (forwarded to `GEV_NEWS_ORIGIN` when set, else answered by Core) | 2 min map refresh; Local re-read when the camera settles 0.8 s in a new half-degree cell below 3,000 km |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
|
||||
|
||||
@@ -1230,6 +1230,7 @@
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/sources/phaseTiming.test.mjs",
|
||||
"scripts/build-census-places.mjs",
|
||||
@@ -1284,6 +1285,13 @@
|
||||
"src/voice/panelLayout.js",
|
||||
"src/voice/textChat.js",
|
||||
"src/voice/textProtocol.js",
|
||||
"src/layers/shipwrecks/records.test.mjs",
|
||||
"src/layers/shipwrecks/cards.test.mjs",
|
||||
"src/layers/shipwrecks/eras.test.mjs",
|
||||
"src/layers/shipwrecks/period.test.mjs",
|
||||
"src/layers/shipwrecks/index.test.mjs",
|
||||
"src/layers/shipwrecks/source.test.mjs",
|
||||
"src/data/shipwrecksProxy.test.mjs",
|
||||
"src/app/sourceComposition.js",
|
||||
"src/app/sourceComposition.test.mjs",
|
||||
"src/data/dataCredits.js",
|
||||
|
||||
@@ -762,6 +762,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
@@ -1162,6 +1163,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
@@ -3044,6 +3046,7 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/layerPanel/RowText.vue",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
"src/ui/layerPanel/RowRange.vue",
|
||||
"src/ui/layerPanel/RowSlider.vue",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
@@ -3173,6 +3176,13 @@
|
||||
"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/layers/shipwrecks/period.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.js",
|
||||
@@ -3982,6 +3992,13 @@
|
||||
"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/layers/shipwrecks/period.js",
|
||||
"src/app/layers/droneAirspace.js",
|
||||
"src/layers/droneAirspace/index.js",
|
||||
"src/layers/droneAirspace/source.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 { newsProxy } from './news.js';
|
||||
import { inaturalistProxy } from './inaturalist/observations.js';
|
||||
@@ -83,6 +84,7 @@ function localProviderPlugins({
|
||||
firePerimetersProxy(),
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
shipwrecksProxy(),
|
||||
droneAirspaceProxy(),
|
||||
newsProxy(),
|
||||
inaturalistProxy(),
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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 source's last good records keep being served this long, marked stale. */
|
||||
const STALE_MAX_MS = 7 * REFRESH_MS;
|
||||
/** After a source fails, it is not asked again for this long. */
|
||||
const RETRY_MS = 15 * 60 * 1000;
|
||||
/** A refresh with fewer than this share of the last good records is refused. */
|
||||
const MIN_KEEP_RATIO = 0.5;
|
||||
// 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(),
|
||||
} = {}) {
|
||||
// Each source keeps its last good records on its own, so one failing (or
|
||||
// answering with nothing usable) never removes the other's wrecks or its
|
||||
// own earlier ones. `retryAt` holds a failed source back for RETRY_MS.
|
||||
const sources = {
|
||||
emodnet: { load: fetchEmodnet, good: null, retryAt: 0 },
|
||||
wikidata: { load: fetchWikidata, good: null, retryAt: 0 },
|
||||
};
|
||||
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() {
|
||||
const payload = await upstreamJson(WIKIDATA_URL, 16 * MIB);
|
||||
if (!Array.isArray(payload?.results?.bindings))
|
||||
throw new Error('invalid_wikidata_results');
|
||||
const records = normalizeWikidataResults(payload);
|
||||
// Wikidata lists over a thousand wrecks; none at all is a broken answer.
|
||||
if (!records.length) throw new Error('invalid_wikidata_results');
|
||||
return records;
|
||||
}
|
||||
|
||||
const fresh = (state, at) =>
|
||||
state.good !== null && at - state.good.fetchedAt < REFRESH_MS;
|
||||
const usable = (state, at) =>
|
||||
state.good !== null && at - state.good.fetchedAt <= STALE_MAX_MS;
|
||||
const due = (state, at) => !fresh(state, at) && at >= state.retryAt;
|
||||
|
||||
/** Refresh the sources that are due; failures keep their last good records. */
|
||||
async function refresh() {
|
||||
const at = now();
|
||||
const names = Object.keys(sources).filter((name) => due(sources[name], at));
|
||||
const results = await Promise.allSettled(
|
||||
names.map((name) => sources[name].load()),
|
||||
);
|
||||
const done = now();
|
||||
results.forEach((result, i) => {
|
||||
const state = sources[names[i]];
|
||||
// A valid answer far smaller than the last good set (a truncated or
|
||||
// half-built upstream) is a failure too, until the old set expires.
|
||||
const shrunk =
|
||||
result.status === 'fulfilled' &&
|
||||
usable(state, done) &&
|
||||
result.value.length < state.good.records.length * MIN_KEEP_RATIO;
|
||||
if (result.status === 'fulfilled' && !shrunk)
|
||||
state.good = { records: result.value, fetchedAt: done };
|
||||
else state.retryAt = done + RETRY_MS;
|
||||
if (shrunk)
|
||||
console.warn(
|
||||
`[shipwrecks] ${names[i]} refresh refused: ${result.value.length} ` +
|
||||
`records against ${state.good.records.length} last good`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The served snapshot: every source with usable records, merged once. */
|
||||
function compose(at) {
|
||||
const sourceFlags = {};
|
||||
// How old each source's records are, on this clock, so the page can
|
||||
// expire them by its own clock without comparing the two.
|
||||
const ages = {};
|
||||
let stale = false;
|
||||
let fetchedAt = null;
|
||||
for (const [name, state] of Object.entries(sources)) {
|
||||
sourceFlags[name] = usable(state, at);
|
||||
ages[name] = sourceFlags[name] ? at - state.good.fetchedAt : null;
|
||||
if (!sourceFlags[name]) continue;
|
||||
if (!fresh(state, at)) stale = true;
|
||||
fetchedAt = Math.min(fetchedAt ?? Infinity, state.good.fetchedAt);
|
||||
}
|
||||
if (!sourceFlags.emodnet && !sourceFlags.wikidata) return null;
|
||||
const key = `${sourceFlags.emodnet && sources.emodnet.good.fetchedAt}:${
|
||||
sourceFlags.wikidata && sources.wikidata.good.fetchedAt
|
||||
}`;
|
||||
if (snapshot?.key !== key) {
|
||||
// The merge writes EMODnet facts into Wikidata records: work on copies
|
||||
// so a source's kept records stay as fetched.
|
||||
const copy = (name) =>
|
||||
sourceFlags[name]
|
||||
? sources[name].good.records.map((record) => ({ ...record }))
|
||||
: [];
|
||||
snapshot = {
|
||||
key,
|
||||
records: mergeShipwreckRecords(copy('emodnet'), copy('wikidata')),
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
fetchedAt,
|
||||
version: key,
|
||||
sources: sourceFlags,
|
||||
ages,
|
||||
maxAgeMs: STALE_MAX_MS,
|
||||
records: snapshot.records,
|
||||
},
|
||||
stale,
|
||||
};
|
||||
}
|
||||
|
||||
async function acquire() {
|
||||
const at = now();
|
||||
if (Object.values(sources).some((state) => due(state, at)))
|
||||
await coalesceProxyRequest(inFlight, 'refresh', refresh).promise;
|
||||
const composed = compose(now());
|
||||
if (!composed) throw new Error('shipwrecks_unavailable');
|
||||
return composed;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,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 { createApplicationNews } from './layers/news.js';
|
||||
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
|
||||
@@ -137,6 +138,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, 39);
|
||||
assert.equal(first.layers.length, 40);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -162,7 +162,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, 39);
|
||||
assert.equal(catalog.layers.length, 40);
|
||||
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']),
|
||||
news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']),
|
||||
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
|
||||
|
||||
@@ -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, 39);
|
||||
assert.equal(actual.layers.length, 40);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -597,6 +597,14 @@ export const DATA_CREDITS = [
|
||||
'News, Local without a news server: headlines from ' +
|
||||
'<a href="https://worldnewsapi.com/" target="_blank" rel="noopener">World News API</a>',
|
||||
},
|
||||
{
|
||||
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:
|
||||
|
||||
@@ -723,6 +723,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: '01', 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, 37);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 37);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 38);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,11 +243,12 @@ 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);
|
||||
}
|
||||
// News holds 00, the first pair: the next token is 01.
|
||||
assert.equal(nextLayerStateToken(), '01');
|
||||
// The allocation rules below read the ledger before News took it.
|
||||
// News holds 00 and Shipwrecks 01, the first pairs: the next token is 02.
|
||||
assert.equal(nextLayerStateToken(), '02');
|
||||
// The allocation rules below read the ledger before either took a pair.
|
||||
const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS };
|
||||
delete beforeNews.news;
|
||||
delete beforeNews.shipwrecks;
|
||||
assert.equal(nextLayerStateToken(beforeNews), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
|
||||
@@ -97,10 +97,10 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
// The ledger before Wildlife Sightings took the last digit and News the first
|
||||
// pair, for the allocation rules written against it.
|
||||
// The ledger before Wildlife Sightings took the last digit and News and
|
||||
// Shipwrecks the first pairs, for the allocation rules written against it.
|
||||
const beforeLastDigitRows = reservationRows.filter(
|
||||
([id]) => id !== 'wildlife-sightings' && id !== 'news',
|
||||
([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id),
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
@@ -122,6 +122,11 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
/ Object\.freeze\(\{\s*id: 'news',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
if (!rows.some(([id]) => id === 'shipwrecks'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -145,7 +150,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, 37);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -159,6 +164,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'drone-airspace': '8',
|
||||
'wildlife-sightings': '9',
|
||||
news: '00',
|
||||
shipwrecks: '01',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -184,9 +190,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '01']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '02']])
|
||||
.future,
|
||||
'01',
|
||||
'02',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -417,19 +423,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
test('checker reads a complete future base ledger and rejects retired-token reuse', () => {
|
||||
withPublishedBase(
|
||||
{
|
||||
rows: [...reservationRows, ['retired-layer', '01']],
|
||||
rows: [...reservationRows, ['retired-layer', '02']],
|
||||
source:
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "01" });\n',
|
||||
'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n',
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 38);
|
||||
assert.equal(published['retired-layer'], '01');
|
||||
assert.equal(Object.keys(published).length, 39);
|
||||
assert.equal(published['retired-layer'], '02');
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '00',
|
||||
newcomer: '02',
|
||||
}),
|
||||
/changed or removed/,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
["s2-ghostmaps", "5"],
|
||||
["satellites", "s"],
|
||||
["sea-marks", "6"],
|
||||
["shipwrecks", "01"],
|
||||
["ski-pistes", "7"],
|
||||
["street-level", "0"],
|
||||
["telegeography-submarine-cables", "u"],
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const WIKIDATA = {
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: { value: 'http://www.wikidata.org/entity/Q25173' },
|
||||
itemLabel: { value: 'Titanic' },
|
||||
coord: { value: 'Point(-49.9469 41.7325)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Upstream fake. Each source answers 'ok', 'down' (HTTP 503) or 'malformed'
|
||||
* (HTTP 200 with a body that is not that source's data); change `state` to
|
||||
* switch between requests.
|
||||
*/
|
||||
function fakeFetch({ wikidataFails = false, emodnetFails = false } = {}) {
|
||||
const calls = [];
|
||||
const state = {
|
||||
emodnet: emodnetFails ? 'down' : 'ok',
|
||||
wikidata: wikidataFails ? 'down' : 'ok',
|
||||
};
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
const href = String(url);
|
||||
const name = href.includes('query.wikidata.org')
|
||||
? 'wikidata'
|
||||
: href.includes('ows.emodnet-humanactivities.eu')
|
||||
? 'emodnet'
|
||||
: null;
|
||||
if (!name) return new Response('', { status: 404 });
|
||||
if (state[name] === 'down') return new Response('', { status: 503 });
|
||||
if (state[name] === 'malformed') return Response.json({});
|
||||
return Response.json(name === 'wikidata' ? WIKIDATA : EMODNET);
|
||||
};
|
||||
const callsTo = (name) =>
|
||||
calls.filter(({ url }) =>
|
||||
url.includes(
|
||||
name === 'wikidata'
|
||||
? 'query.wikidata.org'
|
||||
: 'ows.emodnet-humanactivities',
|
||||
),
|
||||
).length;
|
||||
return { fetchImpl, calls, state, callsTo };
|
||||
}
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
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, 2);
|
||||
const a1 = first.body.records.find((record) => record.name === 'HMS/m A1');
|
||||
assert.equal(a1.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);
|
||||
});
|
||||
|
||||
test("a refresh that gets nothing usable keeps every source's last good wrecks", async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
const first = await request(handler);
|
||||
assert.equal(first.body.records.length, 2);
|
||||
// A day later EMODnet is down and Wikidata answers 200 with no results.
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'malformed';
|
||||
clock = 25 * HOUR;
|
||||
const later = await request(handler);
|
||||
assert.equal(later.status, 200);
|
||||
assert.equal(later.body.records.length, 2, 'no wreck is lost');
|
||||
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
|
||||
assert.equal(later.body.stale, true);
|
||||
assert.equal(later.body.fetchedAt, 0, 'the age of the oldest data served');
|
||||
});
|
||||
|
||||
test('one source failing keeps its own last records while the other refreshes', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
const first = await request(handler);
|
||||
assert.equal((await request(handler)).body.version, first.body.version);
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 25 * HOUR;
|
||||
const later = await request(handler);
|
||||
assert.notEqual(later.body.version, first.body.version, 'new EMODnet data');
|
||||
assert.equal(later.body.records.length, 2);
|
||||
assert.deepEqual(later.body.sources, { emodnet: true, wikidata: true });
|
||||
assert.equal(later.body.stale, true);
|
||||
assert.equal(upstream.callsTo('emodnet'), 2, 'EMODnet was refreshed');
|
||||
assert.deepEqual(later.body.ages, { emodnet: 0, wikidata: 25 * HOUR });
|
||||
assert.equal(later.body.maxAgeMs, 7 * 24 * HOUR);
|
||||
});
|
||||
|
||||
test('a failed source waits before it is asked again', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
await request(handler);
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 25 * HOUR;
|
||||
await request(handler);
|
||||
const asked = upstream.calls.length;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
clock += 60 * 1000;
|
||||
const res = await request(handler);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.stale, true);
|
||||
}
|
||||
assert.equal(upstream.calls.length, asked, 'no upstream call while waiting');
|
||||
clock = 25 * HOUR + 16 * 60 * 1000;
|
||||
upstream.state.emodnet = 'ok';
|
||||
upstream.state.wikidata = 'ok';
|
||||
const back = await request(handler);
|
||||
assert.equal(
|
||||
upstream.calls.length,
|
||||
asked + 2,
|
||||
'both are retried after the wait',
|
||||
);
|
||||
assert.equal(back.body.stale, undefined);
|
||||
});
|
||||
|
||||
test('with no data yet, failures are retried only after the wait', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch({ emodnetFails: true, wikidataFails: true });
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
const asked = upstream.calls.length;
|
||||
clock += 5 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
assert.equal(upstream.calls.length, asked);
|
||||
clock += 11 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502);
|
||||
assert.equal(upstream.calls.length, asked * 2);
|
||||
});
|
||||
|
||||
test('a source older than seven days is no longer served', async () => {
|
||||
let clock = 0;
|
||||
const upstream = fakeFetch();
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({ fetchImpl: upstream.fetchImpl, now: () => clock }),
|
||||
);
|
||||
await request(handler);
|
||||
upstream.state.emodnet = 'down';
|
||||
upstream.state.wikidata = 'down';
|
||||
clock = 7 * 24 * HOUR;
|
||||
assert.equal((await request(handler)).status, 200, 'seven days is served');
|
||||
clock = 7 * 24 * HOUR + 16 * 60 * 1000;
|
||||
assert.equal((await request(handler)).status, 502, 'older is dropped');
|
||||
});
|
||||
|
||||
test('an upstream body over its cap is a failed source, read only to the cap', async () => {
|
||||
const MIB = 1024 * 1024;
|
||||
const chunk = new Uint8Array(MIB).fill(0x20);
|
||||
let sent = 0;
|
||||
let cancelled = false;
|
||||
const endless = () =>
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
sent += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: async (url) =>
|
||||
String(url).includes('query.wikidata.org')
|
||||
? Response.json(WIKIDATA)
|
||||
: new Response(endless()),
|
||||
}),
|
||||
);
|
||||
const res = await request(handler);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.sources, { emodnet: false, wikidata: true });
|
||||
assert.ok(cancelled, 'the oversized body is cancelled');
|
||||
assert.ok(sent <= 34 * MIB, `read ${sent / MIB} MiB of a 32 MiB cap`);
|
||||
});
|
||||
|
||||
test('a refresh far smaller than the last good set is refused until it expires', async () => {
|
||||
let clock = 0;
|
||||
const many = {
|
||||
results: {
|
||||
bindings: Array.from({ length: 10 }, (_, i) => ({
|
||||
item: { value: `http://www.wikidata.org/entity/Q${100 + i}` },
|
||||
itemLabel: { value: `Wreck ${i}` },
|
||||
coord: { value: `Point(${i} 40)` },
|
||||
})),
|
||||
},
|
||||
};
|
||||
let wikidata = many;
|
||||
const handler = handlerOf(
|
||||
shipwrecksProxy({
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
String(url).includes('query.wikidata.org') ? wikidata : EMODNET,
|
||||
),
|
||||
now: () => clock,
|
||||
}),
|
||||
);
|
||||
assert.equal((await request(handler)).body.records.length, 11);
|
||||
wikidata = WIKIDATA;
|
||||
clock = 25 * HOUR;
|
||||
const refused = await request(handler);
|
||||
assert.equal(refused.body.records.length, 11, 'one wreck is not ten');
|
||||
assert.equal(refused.body.stale, true);
|
||||
clock = 8 * 24 * HOUR;
|
||||
const accepted = await request(handler);
|
||||
assert.equal(
|
||||
accepted.body.records.length,
|
||||
2,
|
||||
'taken once the old set expired',
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Bottom-to-top order for near-plane-clamped contact sprite collections. */
|
||||
export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'shipwrecks',
|
||||
'cctv',
|
||||
'firms',
|
||||
'wildlife-sightings',
|
||||
|
||||
@@ -3628,6 +3628,47 @@
|
||||
"News key required": "مفتاح الأخبار مطلوب",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "نقاط نزاع دقيقة في طبقة الأخبار، إلى جانب مقالات GDELT",
|
||||
"Worldwide": "العالم",
|
||||
"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، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.",
|
||||
"All years": "كل السنوات",
|
||||
"Period": "الفترة",
|
||||
"From": "من",
|
||||
"To": "إلى",
|
||||
"First year of the shipwreck period": "السنة الأولى من فترة الغرق",
|
||||
"Last year of the shipwreck period": "السنة الأخيرة من فترة الغرق",
|
||||
"SIGHTING": "مشاهدة",
|
||||
"Show {name} sighting": "إظهار مشاهدة {name}",
|
||||
"seen {age} ago": "شوهد قبل {age}",
|
||||
|
||||
@@ -3308,6 +3308,43 @@
|
||||
"News key required": "Nachrichtenschlüssel erforderlich",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT",
|
||||
"Worldwide": "Weltweit",
|
||||
"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.",
|
||||
"All years": "Alle Jahre",
|
||||
"Period": "Zeitraum",
|
||||
"From": "Von",
|
||||
"To": "Bis",
|
||||
"First year of the shipwreck period": "Erstes Jahr des Schiffbruch-Zeitraums",
|
||||
"Last year of the shipwreck period": "Letztes Jahr des Schiffbruch-Zeitraums",
|
||||
"SIGHTING": "BEOBACHTUNG",
|
||||
"Show {name} sighting": "Beobachtung von {name} anzeigen",
|
||||
"seen {age} ago": "vor {age} gesichtet",
|
||||
|
||||
@@ -3388,6 +3388,44 @@
|
||||
"News key required": "Se necesita una clave de noticias",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Puntos de conflicto precisos en la capa de noticias, junto a los artículos de GDELT",
|
||||
"Worldwide": "Mundial",
|
||||
"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.",
|
||||
"All years": "Todos los años",
|
||||
"Period": "Periodo",
|
||||
"From": "Desde",
|
||||
"To": "Hasta",
|
||||
"First year of the shipwreck period": "Primer año del periodo de naufragios",
|
||||
"Last year of the shipwreck period": "Último año del periodo de naufragios",
|
||||
"SIGHTING": "AVISTAMIENTO",
|
||||
"Show {name} sighting": "Mostrar avistamiento de {name}",
|
||||
"seen {age} ago": "visto hace {age}",
|
||||
|
||||
@@ -3388,6 +3388,44 @@
|
||||
"News key required": "Clé d’actualités requise",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT",
|
||||
"Worldwide": "Monde entier",
|
||||
"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.",
|
||||
"All years": "Toutes les années",
|
||||
"Period": "Période",
|
||||
"From": "Du",
|
||||
"To": "Au",
|
||||
"First year of the shipwreck period": "Première année de la période des naufrages",
|
||||
"Last year of the shipwreck period": "Dernière année de la période des naufrages",
|
||||
"SIGHTING": "OBSERVATION",
|
||||
"Show {name} sighting": "Afficher l'observation : {name}",
|
||||
"seen {age} ago": "vu il y a {age}",
|
||||
|
||||
@@ -3497,5 +3497,43 @@
|
||||
"Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "מסגרת מלאה: דווח על ידי שני הצדדים או באופן עצמאי. מסגרת מקווקוות: טענה. מסגרת דקה: מקור יחיד, או התקשורת של אחד הצדדים.",
|
||||
"Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "גדול יותר: יותר מקורות · דהוי יותר: ישן יותר. כרטיס תמונה: אירוע חמור או מתפתח במהירות עם שני מקורות או יותר.",
|
||||
"Side labels describe the source, not whether a report is true.": "תוויות הצד מתארות את המקור, לא אם הדיווח נכון.",
|
||||
"News items in the feed": "פריטי חדשות בפיד"
|
||||
"News items in the feed": "פריטי חדשות בפיד",
|
||||
"Shipwrecks": "ספינות טרופות",
|
||||
"Before 1900": "לפני 1900",
|
||||
"1900–1945": "1900–1945",
|
||||
"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} מ׳)",
|
||||
"Shipwreck": "ספינה טרופה",
|
||||
"Fatalities: {count}": "הרוגים: {count}",
|
||||
"Survivors: {count}": "ניצולים: {count}",
|
||||
"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 article ↗": "לחץ על הכרטיס כדי לפתוח את הערך ↗",
|
||||
"Click the card to open the source ↗": "לחץ על הכרטיס כדי לפתוח את המקור ↗",
|
||||
"All years": "כל השנים",
|
||||
"Period": "תקופה",
|
||||
"From": "מ־",
|
||||
"First year of the shipwreck period": "השנה הראשונה בתקופת הטביעות",
|
||||
"To": "עד",
|
||||
"Last year of the shipwreck period": "השנה האחרונה בתקופת הטביעות",
|
||||
"Wikidata unavailable; showing EMODnet only": "ויקינתונים אינו זמין; מוצג EMODnet בלבד",
|
||||
"EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג ויקינתונים בלבד",
|
||||
"Shipwrecks unavailable": "ספינות טרופות לא זמינות",
|
||||
"{count} wrecks": {
|
||||
"one": "{count} ספינה טרופה",
|
||||
"two": "{count} ספינות טרופות",
|
||||
"other": "{count} ספינות טרופות"
|
||||
},
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)",
|
||||
"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; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו."
|
||||
}
|
||||
@@ -3388,6 +3388,44 @@
|
||||
"News key required": "Chiave per le notizie richiesta",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Punti di conflitto precisi nel livello Notizie, accanto agli articoli di GDELT",
|
||||
"Worldwide": "Mondo",
|
||||
"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.",
|
||||
"All years": "Tutti gli anni",
|
||||
"Period": "Periodo",
|
||||
"From": "Dal",
|
||||
"To": "Al",
|
||||
"First year of the shipwreck period": "Primo anno del periodo dei naufragi",
|
||||
"Last year of the shipwreck period": "Ultimo anno del periodo dei naufragi",
|
||||
"SIGHTING": "AVVISTAMENTO",
|
||||
"Show {name} sighting": "Mostra avvistamento di {name}",
|
||||
"seen {age} ago": "avvistato {age} fa",
|
||||
|
||||
@@ -3228,6 +3228,42 @@
|
||||
"News key required": "ニュースのキーが必要です",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)",
|
||||
"Worldwide": "世界",
|
||||
"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によります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。",
|
||||
"All years": "全期間",
|
||||
"Period": "期間",
|
||||
"From": "開始",
|
||||
"To": "終了",
|
||||
"First year of the shipwreck period": "難破の期間の開始年",
|
||||
"Last year of the shipwreck period": "難破の期間の終了年",
|
||||
"SIGHTING": "目撃情報",
|
||||
"Show {name} sighting": "{name}の目撃情報を表示",
|
||||
"seen {age} ago": "{age}前に目撃",
|
||||
|
||||
@@ -3388,6 +3388,44 @@
|
||||
"News key required": "Chave de notícias necessária",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Pontos de conflito precisos na camada de notícias, ao lado dos artigos do GDELT",
|
||||
"Worldwide": "Mundo",
|
||||
"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.",
|
||||
"All years": "Todos os anos",
|
||||
"Period": "Período",
|
||||
"From": "Início",
|
||||
"To": "Fim",
|
||||
"First year of the shipwreck period": "Primeiro ano do período dos naufrágios",
|
||||
"Last year of the shipwreck period": "Último ano do período dos naufrágios",
|
||||
"SIGHTING": "AVISTAMENTO",
|
||||
"Show {name} sighting": "Mostrar avistamento de {name}",
|
||||
"seen {age} ago": "visto há {age}",
|
||||
|
||||
@@ -3468,6 +3468,45 @@
|
||||
"News key required": "Нужен ключ новостей",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT",
|
||||
"Worldwide": "Весь мир",
|
||||
"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. Цвет показывает, когда судно затонуло; маркер растёт с числом погибших. Нажмите на маркер, чтобы открыть карточку.",
|
||||
"All years": "Все годы",
|
||||
"Period": "Период",
|
||||
"From": "С",
|
||||
"To": "По",
|
||||
"First year of the shipwreck period": "Первый год периода кораблекрушений",
|
||||
"Last year of the shipwreck period": "Последний год периода кораблекрушений",
|
||||
"SIGHTING": "НАБЛЮДЕНИЕ",
|
||||
"Show {name} sighting": "Показать наблюдение: {name}",
|
||||
"seen {age} ago": "замечено {age} назад",
|
||||
|
||||
@@ -3468,6 +3468,45 @@
|
||||
"News key required": "Потрібен ключ новин",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT",
|
||||
"Worldwide": "Увесь світ",
|
||||
"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. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.",
|
||||
"All years": "Усі роки",
|
||||
"Period": "Період",
|
||||
"From": "З",
|
||||
"To": "По",
|
||||
"First year of the shipwreck period": "Перший рік періоду корабельних аварій",
|
||||
"Last year of the shipwreck period": "Останній рік періоду корабельних аварій",
|
||||
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
|
||||
"Show {name} sighting": "Показати спостереження: {name}",
|
||||
"seen {age} ago": "помічено {age} тому",
|
||||
|
||||
@@ -3228,6 +3228,42 @@
|
||||
"News key required": "需要新闻密钥",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列",
|
||||
"Worldwide": "全球",
|
||||
"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。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。",
|
||||
"All years": "所有年份",
|
||||
"Period": "时段",
|
||||
"From": "起",
|
||||
"To": "止",
|
||||
"First year of the shipwreck period": "沉船时段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船时段的结束年份",
|
||||
"SIGHTING": "目击",
|
||||
"Show {name} sighting": "显示{name}目击",
|
||||
"seen {age} ago": "{age}前目击",
|
||||
|
||||
@@ -3228,6 +3228,42 @@
|
||||
"News key required": "需要新聞金鑰",
|
||||
"Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列",
|
||||
"Worldwide": "全球",
|
||||
"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。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。",
|
||||
"All years": "所有年份",
|
||||
"Period": "時段",
|
||||
"From": "起",
|
||||
"To": "迄",
|
||||
"First year of the shipwreck period": "沉船時段的起始年份",
|
||||
"Last year of the shipwreck period": "沉船時段的結束年份",
|
||||
"SIGHTING": "目擊",
|
||||
"Show {name} sighting": "顯示{name}目擊",
|
||||
"seen {age} ago": "{age}前目擊",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ar": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -47,6 +48,7 @@
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -241,6 +243,7 @@
|
||||
"es": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -397,6 +400,7 @@
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
@@ -611,6 +615,7 @@
|
||||
"it": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
@@ -809,6 +814,7 @@
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -872,6 +878,7 @@
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1027,6 +1034,7 @@
|
||||
"iNaturalist"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1059,6 +1067,7 @@
|
||||
"{division}, {country}"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"Acartia",
|
||||
@@ -1087,6 +1096,7 @@
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"1900–1945",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -1157,6 +1167,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: deaths first, then when and
|
||||
* where, then the ship facts. Victims appear as a count only; free text that
|
||||
* refers to a person by a title or role is dropped by a keyword filter (see
|
||||
* records.js), which a bare name can still pass.
|
||||
* @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,21 @@
|
||||
/** 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';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { eraOf } 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');
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, msg, t, tn } from '../../i18n/index.js';
|
||||
import {
|
||||
registerSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
unregisterSpriteCollection,
|
||||
} from '../../data/spriteOrder.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { ERA_COLORS, ERA_LABELS, describeShipwreck } from './cards.js';
|
||||
import { SHIPWRECK_ERAS, eraOf } from './eras.js';
|
||||
import {
|
||||
inPeriod,
|
||||
normalizePeriod,
|
||||
periodRange,
|
||||
yearExtent,
|
||||
} from './period.js';
|
||||
export * from './records.js';
|
||||
export * from './cards.js';
|
||||
export * from './eras.js';
|
||||
export * from './period.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;
|
||||
/** Wrecks not refreshed for this long leave the map (the proxy's own limit). */
|
||||
const MAX_SHOWN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* When the shown wrecks leave the map if the page cannot refresh, on this
|
||||
* page's clock: the age the proxy reports (its own clock) is subtracted from
|
||||
* the limit, so the two clocks are never compared, and the newest source
|
||||
* decides. A wreck both sources list is one merged record, so the snapshot
|
||||
* goes as a whole, not per source. With no age reported, the snapshot counts
|
||||
* as fresh when it arrives.
|
||||
*/
|
||||
export function snapshotDeadline(snapshot, at) {
|
||||
const limit = snapshot.maxAgeMs ?? MAX_SHOWN_AGE_MS;
|
||||
const ages = ['emodnet', 'wikidata']
|
||||
.filter((name) => snapshot.sources?.[name] !== false)
|
||||
.map((name) => snapshot.ages?.[name] ?? 0);
|
||||
const newest = ages.length ? Math.min(...ages) : 0;
|
||||
return at + limit - newest;
|
||||
}
|
||||
/**
|
||||
* Marker size in pixels: grows with the death toll, 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. It is rasterized at 144 px, four times the
|
||||
* largest marker, so big markers stay sharp on high-density screens.
|
||||
* @param {string} color CSS colour.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function wreckMarkerImage(color) {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" 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,
|
||||
now = () => Date.now(),
|
||||
// One collection for every wreck marker (thousands of them): far cheaper to
|
||||
// draw and rebuild than an entity each. Tests pass a fake.
|
||||
createMarkers = (scene) =>
|
||||
new Cesium.BillboardCollection({
|
||||
scene,
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
}),
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Shipwrecks require a snapshot source');
|
||||
let _viewer = null;
|
||||
let _enabled = false;
|
||||
let _markers = null;
|
||||
let _request = null;
|
||||
let _records = new Map();
|
||||
let _pins = [];
|
||||
let _snapshot = null;
|
||||
let _deadline = null;
|
||||
let _lastError = null;
|
||||
let _clickHandler = null;
|
||||
let _rowListener = null;
|
||||
let _selectedId = null;
|
||||
let _selectedCardId = null;
|
||||
let _selectedLink = null;
|
||||
let _period = { from: null, to: null };
|
||||
let _extent = null;
|
||||
let _range = null;
|
||||
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 year is inside the period (undated ones only for all years). */
|
||||
function visible(record) {
|
||||
return Boolean(record) && inPeriod(record, _range);
|
||||
}
|
||||
|
||||
function applyPin(pin) {
|
||||
const show = pin.inFront && visible(pin.record);
|
||||
if (pin.billboard.show !== show) pin.billboard.show = show;
|
||||
}
|
||||
|
||||
/** Re-apply the period 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]));
|
||||
_extent = yearExtent(records);
|
||||
_range = periodRange(_period, _extent);
|
||||
_pins = [];
|
||||
if (!_markers) return;
|
||||
_markers.removeAll();
|
||||
// Within one collection later markers draw on top: add by rising death
|
||||
// toll so a bigger toll covers smaller neighbours.
|
||||
const ordered = [...records].sort(
|
||||
(a, b) => (a.casualties || 0) - (b.casualties || 0),
|
||||
);
|
||||
for (const record of ordered) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
|
||||
const size = markerPixelSize(record.casualties);
|
||||
const billboard = _markers.add({
|
||||
id: `${PICK_PREFIX}${record.id}`,
|
||||
position,
|
||||
image: _images[eraOf(record)],
|
||||
width: size,
|
||||
height: size,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const pin = {
|
||||
billboard,
|
||||
record,
|
||||
position,
|
||||
inFront: true,
|
||||
get show() {
|
||||
return this.inFront;
|
||||
},
|
||||
set show(value) {
|
||||
this.inFront = value;
|
||||
applyPin(this);
|
||||
},
|
||||
};
|
||||
applyPin(pin);
|
||||
_pins.push(pin);
|
||||
}
|
||||
_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);
|
||||
}
|
||||
|
||||
/** Wrecks shown per era, in one pass over the records. */
|
||||
function eraCounts() {
|
||||
const counts = Object.fromEntries(SHIPWRECK_ERAS.map((era) => [era, 0]));
|
||||
for (const record of _records.values())
|
||||
if (inPeriod(record, _range)) counts[eraOf(record)]++;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** The years that have a dated wreck, plus `extra` ones, newest first. */
|
||||
function yearsInData(extra = []) {
|
||||
const years = new Set(extra);
|
||||
for (const record of _records.values())
|
||||
if (Number.isInteger(record.year)) years.add(record.year);
|
||||
return [...years].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/** The start and end year pickers, side by side. */
|
||||
function periodControls() {
|
||||
if (!_extent || _extent.min === _extent.max) return {};
|
||||
const range = periodRange(_period, _extent);
|
||||
const valueText = range.all
|
||||
? t('All years')
|
||||
: range.from === range.to
|
||||
? String(range.from)
|
||||
: `${range.from}–${range.to}`;
|
||||
// Only years with a wreck: a picker of every year since the oldest
|
||||
// dated wreck would run to well over a thousand entries.
|
||||
// The chosen ends stay listed even when no wreck sank that year.
|
||||
const options = yearsInData([range.from, range.to]).map((year) => ({
|
||||
value: year,
|
||||
label: String(year),
|
||||
}));
|
||||
return {
|
||||
range: {
|
||||
id: 'period',
|
||||
label: t('Period'),
|
||||
valueText,
|
||||
from: {
|
||||
label: t('From'),
|
||||
ariaLabel: t('First year of the shipwreck period'),
|
||||
value: range.from,
|
||||
},
|
||||
to: {
|
||||
label: t('To'),
|
||||
ariaLabel: t('Last year of the shipwreck period'),
|
||||
value: range.to,
|
||||
},
|
||||
options,
|
||||
params: (end, value) => ({ [end]: Number(value) }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
_markers = createMarkers(viewer.scene);
|
||||
_markers.show = false;
|
||||
viewer.scene.primitives.add(_markers);
|
||||
registerSpriteCollection(SHIPWRECKS_LAYER_ID, _markers);
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_markers) _markers.show = true;
|
||||
restoreSpriteOrder(_viewer);
|
||||
installClickHandler();
|
||||
if (_pins.length) _horizon.update();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
if (_markers) _markers.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_horizon.stop();
|
||||
render();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_markers) 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;
|
||||
// The page checks every 6 h; the proxy refreshes once a day. Only a
|
||||
// new snapshot rebuilds the markers.
|
||||
const unchanged =
|
||||
typeof snapshot.version === 'string' &&
|
||||
snapshot.version === _snapshot?.version &&
|
||||
_pins.length > 0;
|
||||
_snapshot = snapshot;
|
||||
_deadline = snapshotDeadline(snapshot, now());
|
||||
_lastError =
|
||||
snapshot.sources.emodnet && snapshot.sources.wikidata
|
||||
? null
|
||||
: snapshot.sources.emodnet
|
||||
? msg('Wikidata unavailable; showing EMODnet only')
|
||||
: msg('EMODnet unavailable; showing Wikidata only');
|
||||
if (!unchanged) 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');
|
||||
// What is on the map was not refreshed: say so, and drop it once
|
||||
// even its newest source is older than the proxy would serve.
|
||||
if (_snapshot) {
|
||||
if (now() >= _deadline) {
|
||||
_snapshot = null;
|
||||
_deadline = null;
|
||||
rebuild([]);
|
||||
} else _snapshot = { ..._snapshot, stale: true };
|
||||
}
|
||||
notifyRow();
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
layer.disable();
|
||||
if (_markers) {
|
||||
unregisterSpriteCollection(SHIPWRECKS_LAYER_ID, _markers);
|
||||
const primitives = viewer?.scene?.primitives;
|
||||
if (primitives && !primitives.isDestroyed?.())
|
||||
primitives.remove(_markers);
|
||||
_markers = null;
|
||||
}
|
||||
_records = new Map();
|
||||
_pins = [];
|
||||
_snapshot = null;
|
||||
_deadline = null;
|
||||
_viewer = null;
|
||||
_rowListener = null;
|
||||
},
|
||||
|
||||
/** The period filter: `from` and `to` are years (null = oldest / latest in the data). */
|
||||
getParams() {
|
||||
return { ..._period };
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
const next = normalizePeriod(params, _period);
|
||||
if (next.from === _period.from && next.to === _period.to) return true;
|
||||
_period = next;
|
||||
_range = periodRange(_period, _extent);
|
||||
applyFilter();
|
||||
return true;
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
_rowListener = listener;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
const counts = eraCounts();
|
||||
return {
|
||||
chips: [],
|
||||
...periodControls(),
|
||||
// The colours are a legend: when the ship sank.
|
||||
legend: SHIPWRECK_ERAS.map((era) => ({
|
||||
label: t(ERA_LABELS[era]),
|
||||
count: counts[era],
|
||||
color: ERA_COLORS[era],
|
||||
blurb: tn(counts[era], '{count} wreck', '{count} wrecks', {
|
||||
count: formatNumber(counts[era]),
|
||||
}),
|
||||
})),
|
||||
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,383 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import {
|
||||
createShipwrecksLayer,
|
||||
markerPixelSize,
|
||||
snapshotDeadline,
|
||||
} from './index.js';
|
||||
|
||||
const wreck = (id, extra = {}) => ({
|
||||
id,
|
||||
source: 'wikidata',
|
||||
name: id,
|
||||
lat: 50,
|
||||
lon: -1,
|
||||
year: 1912,
|
||||
date: null,
|
||||
casualties: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const snapshot = (records, version = 'v1') => ({
|
||||
records,
|
||||
fetchedAt: 1,
|
||||
stale: false,
|
||||
version,
|
||||
sources: { emodnet: true, wikidata: true },
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** A fake billboard collection that records what the layer adds. */
|
||||
function fakeMarkers() {
|
||||
const collection = {
|
||||
show: true,
|
||||
billboards: [],
|
||||
rebuilds: 0,
|
||||
add(options) {
|
||||
const billboard = { show: true, ...options };
|
||||
collection.billboards.push(billboard);
|
||||
return billboard;
|
||||
},
|
||||
removeAll() {
|
||||
collection.rebuilds++;
|
||||
collection.billboards = [];
|
||||
},
|
||||
};
|
||||
return collection;
|
||||
}
|
||||
|
||||
function harness({ snapshots, now = () => 1 } = {}) {
|
||||
const markers = fakeMarkers();
|
||||
const primitives = [];
|
||||
const cards = [];
|
||||
const handlers = [];
|
||||
const opened = [];
|
||||
let pickResult = null;
|
||||
const viewer = {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(-1, 50, 2e6) },
|
||||
scene: {
|
||||
canvas: {},
|
||||
requestRender() {},
|
||||
pick: () => pickResult,
|
||||
preRender: { addEventListener: () => () => {} },
|
||||
primitives: {
|
||||
add: (primitive) => primitives.push(primitive),
|
||||
remove: (primitive) =>
|
||||
primitives.splice(primitives.indexOf(primitive), 1),
|
||||
raiseToTop() {},
|
||||
contains: (primitive) => primitives.includes(primitive),
|
||||
},
|
||||
},
|
||||
};
|
||||
const queue = [...(snapshots || [])];
|
||||
const requests = [];
|
||||
const layer = createShipwrecksLayer({
|
||||
source: {
|
||||
getSnapshot({ signal }) {
|
||||
requests.push(signal);
|
||||
const next = queue.shift();
|
||||
if (next instanceof Error) return Promise.reject(next);
|
||||
return next instanceof Promise ? next : Promise.resolve(next);
|
||||
},
|
||||
},
|
||||
createMarkers: () => markers,
|
||||
overlayHost: {
|
||||
setEntries: (_source, entries) => cards.push(entries),
|
||||
setVisible() {},
|
||||
hitTest: () => null,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => {
|
||||
const handler = {
|
||||
setInputAction(callback) {
|
||||
handler.click = callback;
|
||||
},
|
||||
destroy() {
|
||||
handler.destroyed = true;
|
||||
},
|
||||
};
|
||||
handlers.push(handler);
|
||||
return handler;
|
||||
},
|
||||
picking,
|
||||
pointer: { isPointerFree: () => true },
|
||||
openExternal: (url) => opened.push(url),
|
||||
now: () => now(),
|
||||
});
|
||||
layer.init(viewer);
|
||||
return {
|
||||
layer,
|
||||
markers,
|
||||
primitives,
|
||||
cards,
|
||||
handlers,
|
||||
requests,
|
||||
opened,
|
||||
pick(id) {
|
||||
pickResult = id ? { id: `shipwreck:${id}` } : null;
|
||||
},
|
||||
lastCard: () => cards.at(-1),
|
||||
};
|
||||
}
|
||||
|
||||
test('markers live in one collection, shown only while the layer is on', async () => {
|
||||
const h = harness({ snapshots: [snapshot([wreck('a'), wreck('b')])] });
|
||||
assert.deepEqual(h.primitives, [h.markers]);
|
||||
assert.equal(h.markers.show, false);
|
||||
h.layer.enable();
|
||||
assert.equal(h.markers.show, true);
|
||||
assert.equal(await h.layer.update(), true);
|
||||
assert.equal(h.markers.billboards.length, 2);
|
||||
assert.equal(h.layer.getStats().count, 2);
|
||||
h.layer.disable();
|
||||
assert.equal(h.markers.show, false);
|
||||
assert.equal(h.handlers[0].destroyed, true);
|
||||
h.layer.destroy();
|
||||
assert.deepEqual(h.primitives, [], 'the collection leaves the scene');
|
||||
});
|
||||
|
||||
test('markers sit on the wreck: no eye offset, bigger toll drawn last', async () => {
|
||||
const h = harness({
|
||||
snapshots: [
|
||||
snapshot([
|
||||
wreck('big', { casualties: 1500 }),
|
||||
wreck('none'),
|
||||
wreck('small', { casualties: 3 }),
|
||||
]),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
const ids = h.markers.billboards.map((billboard) => billboard.id);
|
||||
assert.deepEqual(ids, ['shipwreck:none', 'shipwreck:small', 'shipwreck:big']);
|
||||
for (const billboard of h.markers.billboards)
|
||||
assert.equal(billboard.eyeOffset, undefined);
|
||||
assert.equal(h.markers.billboards[2].width, markerPixelSize(1500));
|
||||
});
|
||||
|
||||
test('an unchanged snapshot does not rebuild the markers', async () => {
|
||||
const records = [wreck('a')];
|
||||
const h = harness({
|
||||
snapshots: [
|
||||
snapshot(records, 'v1'),
|
||||
snapshot(records, 'v1'),
|
||||
snapshot([wreck('a'), wreck('b')], 'v2'),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
await h.layer.update();
|
||||
assert.equal(h.markers.rebuilds, 1);
|
||||
await h.layer.update();
|
||||
assert.equal(h.markers.rebuilds, 2);
|
||||
assert.equal(h.markers.billboards.length, 2);
|
||||
});
|
||||
|
||||
test('a response that arrives after the layer is turned off is ignored', async () => {
|
||||
const late = deferred();
|
||||
const h = harness({ snapshots: [late.promise] });
|
||||
h.layer.enable();
|
||||
const pending = h.layer.update();
|
||||
h.layer.disable();
|
||||
assert.equal(h.requests[0].aborted, true);
|
||||
late.resolve(snapshot([wreck('a')]));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.markers.billboards.length, 0);
|
||||
});
|
||||
|
||||
test('a click opens the card; a period that excludes the wreck closes it', async () => {
|
||||
const h = harness({
|
||||
snapshots: [
|
||||
snapshot([wreck('old', { year: 1850 }), wreck('new', { year: 1990 })]),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.pick('old');
|
||||
h.handlers[0].click({ position: { x: 1, y: 1 } });
|
||||
const [card] = h.lastCard();
|
||||
assert.equal(card.id, 'shipwreck-card:old');
|
||||
h.layer.setParams({ from: 1900, to: 1990 });
|
||||
assert.deepEqual(h.lastCard(), [], 'the hidden wreck loses its card');
|
||||
const shown = h.markers.billboards.filter((billboard) => billboard.show);
|
||||
assert.deepEqual(
|
||||
shown.map((billboard) => billboard.id),
|
||||
['shipwreck:new'],
|
||||
);
|
||||
assert.equal(h.layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('the year pickers list only years with a wreck, and each era is counted', async () => {
|
||||
const h = harness({
|
||||
snapshots: [
|
||||
snapshot([
|
||||
wreck('a', { year: 300 }),
|
||||
wreck('b', { year: 1912 }),
|
||||
wreck('c', { year: 1912 }),
|
||||
wreck('d', { year: 1990 }),
|
||||
wreck('e', { year: null }),
|
||||
]),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
let controls = h.layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
controls.range.options.map((option) => option.value),
|
||||
[1990, 1912, 300],
|
||||
);
|
||||
assert.deepEqual(
|
||||
controls.legend.map((era) => era.count),
|
||||
[1, 2, 1, 1],
|
||||
);
|
||||
h.layer.setParams({ from: 1900, to: 1950 });
|
||||
controls = h.layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
controls.range.options.map((option) => option.value),
|
||||
[1990, 1950, 1912, 1900, 300],
|
||||
'the chosen ends are listed too',
|
||||
);
|
||||
assert.deepEqual(
|
||||
controls.legend.map((era) => era.count),
|
||||
[0, 2, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
test('a source without a version rebuilds on every new snapshot', async () => {
|
||||
const bare = (records) => {
|
||||
const value = snapshot(records);
|
||||
delete value.version;
|
||||
return value;
|
||||
};
|
||||
const h = harness({
|
||||
snapshots: [
|
||||
bare([wreck('a')]),
|
||||
bare([wreck('a'), wreck('b')]),
|
||||
{ ...snapshot([wreck('a')]), version: null },
|
||||
{ ...snapshot([wreck('a'), wreck('b'), wreck('c')]), version: null },
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
for (const expected of [1, 2, 1, 3]) {
|
||||
await h.layer.update();
|
||||
assert.equal(h.markers.billboards.length, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed refresh marks the shown wrecks stale, and clears them after seven days', async () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
let clock = 1;
|
||||
const h = harness({
|
||||
now: () => clock,
|
||||
snapshots: [
|
||||
snapshot([wreck('a')]),
|
||||
new Error('Shipwrecks unavailable (HTTP 502)'),
|
||||
new Error('Shipwrecks unavailable (HTTP 502)'),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
clock = 1 + 2 * DAY;
|
||||
assert.equal(await h.layer.update(), false);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
assert.equal(h.layer.getStats().count, 1, 'still shown, as stale');
|
||||
clock = 1 + 8 * DAY;
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().count, 0, 'expired wrecks leave the map');
|
||||
assert.equal(h.markers.billboards.length, 0);
|
||||
});
|
||||
|
||||
test('expiry runs on the page clock and keeps every wreck until the newest source is too old', async () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const down = () => new Error('Shipwrecks unavailable (HTTP 502)');
|
||||
// The page clock is far from the proxy's fetchedAt: only reported ages count.
|
||||
let clock = 5e12;
|
||||
const h = harness({
|
||||
now: () => clock,
|
||||
snapshots: [
|
||||
{
|
||||
...snapshot([
|
||||
wreck('fresh', { source: 'emodnet' }),
|
||||
wreck('merged', { source: 'wikidata', alsoIn: 'emodnet' }),
|
||||
]),
|
||||
fetchedAt: 1,
|
||||
ages: { emodnet: 0, wikidata: 6.5 * DAY },
|
||||
maxAgeMs: 7 * DAY,
|
||||
},
|
||||
down(),
|
||||
down(),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
clock += 1 * DAY;
|
||||
await h.layer.update();
|
||||
assert.equal(
|
||||
h.layer.getStats().count,
|
||||
2,
|
||||
'nothing leaves while EMODnet is in date',
|
||||
);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
clock += 6.5 * DAY;
|
||||
await h.layer.update();
|
||||
assert.equal(
|
||||
h.layer.getStats().count,
|
||||
0,
|
||||
'all leave once the newest is too old',
|
||||
);
|
||||
});
|
||||
|
||||
test('a snapshot with no timestamp or ages still expires seven days after it arrived', async () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
let clock = 100;
|
||||
const h = harness({
|
||||
now: () => clock,
|
||||
snapshots: [
|
||||
{ ...snapshot([wreck('a')]), fetchedAt: null },
|
||||
new Error('down'),
|
||||
],
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
clock += 8 * DAY;
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('the page deadline subtracts the newest available source age from the limit', () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const both = { emodnet: true, wikidata: true };
|
||||
assert.equal(
|
||||
snapshotDeadline(
|
||||
{
|
||||
sources: both,
|
||||
ages: { emodnet: 2 * DAY, wikidata: 6.5 * DAY },
|
||||
maxAgeMs: 5 * DAY,
|
||||
},
|
||||
100,
|
||||
),
|
||||
100 + 3 * DAY,
|
||||
);
|
||||
assert.equal(
|
||||
snapshotDeadline(
|
||||
{
|
||||
sources: { emodnet: false, wikidata: true },
|
||||
ages: { emodnet: null, wikidata: 4 * DAY },
|
||||
maxAgeMs: 5 * DAY,
|
||||
},
|
||||
0,
|
||||
),
|
||||
1 * DAY,
|
||||
'an unavailable source does not count',
|
||||
);
|
||||
assert.equal(snapshotDeadline({ sources: both }, 0), 7 * DAY, 'defaults');
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The sinking year, or null when it is unknown (ancient wrecks dated only by
|
||||
* period, "C1st AD", have none).
|
||||
*/
|
||||
export function recordYear(record) {
|
||||
return Number.isInteger(record?.year) ? record.year : null;
|
||||
}
|
||||
|
||||
/** First and last year present in the records (null when none is dated). */
|
||||
export function yearExtent(records) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const record of records) {
|
||||
const year = recordYear(record);
|
||||
if (year === null) continue;
|
||||
if (year < min) min = year;
|
||||
if (year > max) max = year;
|
||||
}
|
||||
return Number.isFinite(min) ? { min, max } : null;
|
||||
}
|
||||
|
||||
function yearParam(value) {
|
||||
if (value === null) return null;
|
||||
const year =
|
||||
typeof value === 'string' && /^-?\d+$/.test(value) ? Number(value) : value;
|
||||
return Number.isInteger(year) && Math.abs(year) < 10000 ? year : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a (partial) period request into the current one. `from: null` means
|
||||
* the first year in the data and `to: null` the latest; an invalid year keeps
|
||||
* the current one, and a start after the end is stored as the reverse.
|
||||
* @param {{from?: number|string|null, to?: number|string|null}} params
|
||||
* @param {{from: number|null, to: number|null}} current
|
||||
* @returns {{from: number|null, to: number|null}}
|
||||
*/
|
||||
export function normalizePeriod(
|
||||
params = {},
|
||||
current = { from: null, to: null },
|
||||
) {
|
||||
const next = { ...current };
|
||||
for (const end of ['from', 'to']) {
|
||||
if (!(end in params)) continue;
|
||||
const year = yearParam(params[end]);
|
||||
if (year !== undefined) next[end] = year;
|
||||
}
|
||||
// Stored in the order it is shown, so a later edit to one end (the picker
|
||||
// sends only that end) changes the end the reader picked, not the other.
|
||||
if (Number.isInteger(next.from) && Number.isInteger(next.to))
|
||||
if (next.from > next.to) [next.from, next.to] = [next.to, next.from];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive year range a period covers over the data's extent. Years are
|
||||
* clamped to the data, and a start after the end is read as the reverse.
|
||||
* @returns {{from: number, to: number, all: boolean}|null}
|
||||
*/
|
||||
export function periodRange(period, extent) {
|
||||
if (!extent) return null;
|
||||
const clamp = (year, fallback) =>
|
||||
Math.min(extent.max, Math.max(extent.min, year ?? fallback));
|
||||
const start = clamp(period.from, extent.min);
|
||||
const end = clamp(period.to, extent.max);
|
||||
const from = Math.min(start, end);
|
||||
const to = Math.max(start, end);
|
||||
return { from, to, all: from === extent.min && to === extent.max };
|
||||
}
|
||||
|
||||
/** Whether a record falls in the range; undated records only when all years show. */
|
||||
export function inPeriod(record, range) {
|
||||
if (!range || range.all) return true;
|
||||
const year = recordYear(record);
|
||||
return year !== null && year >= range.from && year <= range.to;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
inPeriod,
|
||||
normalizePeriod,
|
||||
periodRange,
|
||||
recordYear,
|
||||
yearExtent,
|
||||
} from './period.js';
|
||||
|
||||
const RECORDS = [
|
||||
{ year: 1588 },
|
||||
{ year: 1912 },
|
||||
{ year: 2012 },
|
||||
{ year: null, ancient: true },
|
||||
];
|
||||
|
||||
test('years come from the record; the extent ignores undated wrecks', () => {
|
||||
assert.equal(recordYear(RECORDS[1]), 1912);
|
||||
assert.equal(recordYear(RECORDS[3]), null);
|
||||
assert.deepEqual(yearExtent(RECORDS), { min: 1588, max: 2012 });
|
||||
assert.equal(yearExtent([{ year: null }]), null);
|
||||
});
|
||||
|
||||
test('all years show every wreck; a narrower period hides undated ones', () => {
|
||||
const extent = yearExtent(RECORDS);
|
||||
const all = periodRange({ from: null, to: null }, extent);
|
||||
assert.deepEqual(all, { from: 1588, to: 2012, all: true });
|
||||
assert.ok(RECORDS.every((record) => inPeriod(record, all)));
|
||||
const wars = periodRange(normalizePeriod({ from: '1914', to: 1945 }), extent);
|
||||
assert.deepEqual(
|
||||
RECORDS.map((record) => inPeriod(record, wars)),
|
||||
[false, false, false, false],
|
||||
);
|
||||
const reversed = periodRange({ from: 1950, to: 1900 }, extent);
|
||||
assert.deepEqual(reversed, { from: 1900, to: 1950, all: false });
|
||||
assert.equal(inPeriod(RECORDS[1], reversed), true);
|
||||
});
|
||||
|
||||
test('a reversed period is stored as shown, so editing one end keeps the other', () => {
|
||||
const reversed = normalizePeriod({ from: 1950, to: 1900 });
|
||||
assert.deepEqual(reversed, { from: 1900, to: 1950 });
|
||||
// The picker shows 1900–1950; the reader changes "From" to 1920.
|
||||
assert.deepEqual(normalizePeriod({ from: 1920 }, reversed), {
|
||||
from: 1920,
|
||||
to: 1950,
|
||||
});
|
||||
// Moving "From" past "To" reads as the reverse, never an empty range.
|
||||
assert.deepEqual(normalizePeriod({ from: 1960 }, { from: 1900, to: 1950 }), {
|
||||
from: 1950,
|
||||
to: 1960,
|
||||
});
|
||||
// One open end stays open.
|
||||
assert.deepEqual(normalizePeriod({ from: 1960 }, { from: null, to: null }), {
|
||||
from: 1960,
|
||||
to: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
// 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, the end of service (P576) included,
|
||||
* 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 ?endPrecision ?deaths ?survivors ?typeLabel ?enwiki ?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 p:P576 [ a wikibase:BestRank ; psv:P576 [ wikibase:timeValue ?end ; wikibase:timePrecision ?endPrecision ] ] }
|
||||
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/> }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en,mul,fr,de,es,it,pt,ru,uk,ja,zh,ar". }
|
||||
}`;
|
||||
|
||||
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 is about ships, so text that refers to a person by a title
|
||||
// or role is dropped whole. This is a keyword filter: a bare name with none of
|
||||
// these words still passes.
|
||||
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 none of the person keywords 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a provenance or link value points at wrecksite.eu, in any form. */
|
||||
function fromWrecksite(value) {
|
||||
return typeof value === 'string' && /wrecksite/i.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize EMODnet's shipwreck GeoJSON. Records the portal took from
|
||||
* wrecksite.eu, named in the provenance or linked as the record's page, 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;
|
||||
// Checked on the raw values: the shown origin is cut to 80 characters,
|
||||
// and a long provenance list can name wrecksite.eu after the cut.
|
||||
if (fromWrecksite(p.source_inf) || fromWrecksite(p.website1)) continue;
|
||||
const origin = clean(p.source_inf, 80);
|
||||
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. A year
|
||||
* before AD 100 ("-0299", 300 BC) has no place on the year picker, so it is
|
||||
* kept as ancient with its year as text, like EMODnet's period-dated wrecks.
|
||||
* @returns {{date: string|null, year: number|null, dating: string|null}}
|
||||
*/
|
||||
function wikidataTime(value, precision) {
|
||||
const none = { date: null, year: null, dating: null };
|
||||
const match = /^([+-]?)(\d{4,})-(\d{2})-(\d{2})T/.exec(value || '');
|
||||
if (!match) return none;
|
||||
// A value without a precision is only trusted to the year.
|
||||
const p = precision == null ? 9 : Number(precision);
|
||||
// 11 = day; 10 = month; 9 = year; below 9 is a decade or coarser.
|
||||
if (!Number.isFinite(p) || p < 9) return none;
|
||||
const signed = Number(`${match[1] === '-' ? '-' : ''}${match[2]}`);
|
||||
if (signed < 100) {
|
||||
// The query service writes XSD 1.1 years: 0000 is 1 BC, -0299 is 300 BC.
|
||||
const dating = signed <= 0 ? `${1 - signed} BC` : `AD ${signed}`;
|
||||
return { date: null, year: null, dating };
|
||||
}
|
||||
const year = parseYear(String(signed));
|
||||
if (year === null) return none;
|
||||
if (p >= 11)
|
||||
return { date: `${match[2]}-${match[3]}-${match[4]}`, year, dating: null };
|
||||
return { date: null, year, dating: null };
|
||||
}
|
||||
|
||||
/** Whether a record (or a parsed time) says when the ship sank. */
|
||||
function hasWhen(when) {
|
||||
return Boolean(when.date || Number.isInteger(when.year) || when.dating);
|
||||
}
|
||||
|
||||
/** Copy when a ship sank from one record (or parsed time) to another. */
|
||||
function takeWhen(target, from) {
|
||||
target.date = from.date ?? null;
|
||||
target.year = from.year ?? null;
|
||||
target.dating = from.dating ?? null;
|
||||
target.ancient = Boolean(from.dating) || Boolean(from.ancient);
|
||||
}
|
||||
|
||||
/** "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'), value('endPrecision'));
|
||||
const known = byItem.get(qid);
|
||||
if (known) {
|
||||
// Extra rows only add what the first one lacked.
|
||||
known.vesselType ||= clean(value('typeLabel'), 60);
|
||||
if (!hasWhen(known)) takeWhen(known, hasWhen(sunk) ? sunk : ended);
|
||||
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 = hasWhen(sunk) ? 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: when.dating,
|
||||
ancient: Boolean(when.dating),
|
||||
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:
|
||||
// English Wikipedia, else the Wikidata item, which links the
|
||||
// article in every language that has one.
|
||||
wikiUrl(value('enwiki')) || `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 (!hasWhen(twin)) takeWhen(twin, record);
|
||||
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 (!hasWhen(ship)) {
|
||||
takeWhen(ship, event);
|
||||
} 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;
|
||||
}
|
||||
if (!hasWhen(twin)) takeWhen(twin, record);
|
||||
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,314 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mergeShipwreckRecords,
|
||||
normalizeEmodnetFeatures,
|
||||
normalizeShipwreckSnapshot,
|
||||
normalizeWikidataResults,
|
||||
parseYear,
|
||||
WIKIDATA_QUERY,
|
||||
} 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]),
|
||||
],
|
||||
});
|
||||
const ids = normalizeEmodnetFeatures({
|
||||
features: [
|
||||
feature('w.3', { name: 'Copied', source_inf: 'www.wrecksite.eu' }),
|
||||
feature('w.5', {
|
||||
source_inf: `${'SHOM; '.repeat(20)}wrecksite.eu`,
|
||||
}),
|
||||
feature('w.6', { website1: 'https://www.wrecksite.eu/wreck.aspx?1' }),
|
||||
feature('w.7', { website1: 'wrecksite.eu/wreck/1' }),
|
||||
feature('w.8', { name: 'Kept', source_inf: 'SHOM' }),
|
||||
],
|
||||
}).map((record) => record.id);
|
||||
assert.deepEqual(ids, ['emodnet:w.8'], 'wrecksite.eu rows are all skipped');
|
||||
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' },
|
||||
sunkPrecision: { value: '11' },
|
||||
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);
|
||||
});
|
||||
|
||||
test('a date before AD 100 is kept as ancient text, never read as an AD year', () => {
|
||||
const entity = (q) => ({ value: `http://www.wikidata.org/entity/${q}` });
|
||||
const [bc, earlyAd, roman] = normalizeWikidataResults({
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: entity('Q1'),
|
||||
itemLabel: { value: 'Antikythera wreck' },
|
||||
coord: { value: 'Point(23.3 35.9)' },
|
||||
sunk: { value: '-0299-01-01T00:00:00Z' },
|
||||
sunkPrecision: { value: '11' },
|
||||
},
|
||||
{
|
||||
item: entity('Q2'),
|
||||
itemLabel: { value: 'Early wreck' },
|
||||
coord: { value: 'Point(12 40)' },
|
||||
sunk: { value: '0050-01-01T00:00:00Z' },
|
||||
sunkPrecision: { value: '9' },
|
||||
},
|
||||
{
|
||||
item: entity('Q3'),
|
||||
itemLabel: { value: 'Roman wreck' },
|
||||
coord: { value: 'Point(13 41)' },
|
||||
sunk: { value: '0300-01-01T00:00:00Z' },
|
||||
sunkPrecision: { value: '9' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(bc.year, null, '300 BC is not the year 300');
|
||||
assert.equal(bc.date, null, 'no day for a BC date');
|
||||
assert.equal(bc.dating, '300 BC');
|
||||
assert.equal(bc.ancient, true);
|
||||
assert.equal(earlyAd.year, null);
|
||||
assert.equal(earlyAd.dating, 'AD 50');
|
||||
assert.equal(earlyAd.ancient, true);
|
||||
assert.equal(roman.year, 300);
|
||||
assert.equal(roman.dating, null);
|
||||
});
|
||||
|
||||
test('an end-of-service date keeps its precision: a year-only one is no day', () => {
|
||||
const entity = (q) => ({ value: `http://www.wikidata.org/entity/${q}` });
|
||||
const [yearOnly, dayPrecise, unknown] = normalizeWikidataResults({
|
||||
results: {
|
||||
bindings: [
|
||||
{
|
||||
item: entity('Q11'),
|
||||
itemLabel: { value: 'Year only' },
|
||||
coord: { value: 'Point(1 50)' },
|
||||
end: { value: '1917-01-01T00:00:00Z' },
|
||||
endPrecision: { value: '9' },
|
||||
},
|
||||
{
|
||||
item: entity('Q12'),
|
||||
itemLabel: { value: 'Day precise' },
|
||||
coord: { value: 'Point(2 50)' },
|
||||
end: { value: '1917-03-04T00:00:00Z' },
|
||||
endPrecision: { value: '11' },
|
||||
},
|
||||
{
|
||||
item: entity('Q13'),
|
||||
itemLabel: { value: 'No precision' },
|
||||
coord: { value: 'Point(3 50)' },
|
||||
end: { value: '1917-01-01T00:00:00Z' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(yearOnly.date, null, 'not shown as 1 Jan 1917');
|
||||
assert.equal(yearOnly.year, 1917);
|
||||
assert.equal(dayPrecise.date, '1917-03-04');
|
||||
assert.equal(unknown.date, null, 'an unknown precision is a year only');
|
||||
assert.equal(unknown.year, 1917);
|
||||
});
|
||||
|
||||
test('the query reads the end of service with its precision', () => {
|
||||
assert.match(
|
||||
WIKIDATA_QUERY,
|
||||
/psv:P576 \[ wikibase:timeValue \?end ; wikibase:timePrecision \?endPrecision \]/,
|
||||
);
|
||||
assert.doesNotMatch(WIKIDATA_QUERY, /wdt:P576/);
|
||||
});
|
||||
|
||||
test('labels and links do not default to one other language', () => {
|
||||
assert.doesNotMatch(WIKIDATA_QUERY, /ptwiki|pt\.wikipedia/);
|
||||
assert.match(WIKIDATA_QUERY, /wikibase:language "en,mul,/);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { normalizeShipwreckSnapshot } from './records.js';
|
||||
|
||||
const age = (value) => (Number.isFinite(value) && value >= 0 ? value : null);
|
||||
|
||||
/** 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,
|
||||
// Changes whenever either source's data does; null from an older proxy.
|
||||
version: typeof payload.version === 'string' ? payload.version : null,
|
||||
// Each source's age on the proxy's clock (null when unknown).
|
||||
ages: {
|
||||
emodnet: age(payload.ages?.emodnet),
|
||||
wikidata: age(payload.ages?.wikidata),
|
||||
},
|
||||
maxAgeMs: age(payload.maxAgeMs),
|
||||
sources: {
|
||||
emodnet: payload.sources?.emodnet !== false,
|
||||
wikidata: payload.sources?.wikidata !== false,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createShipwrecksSource } from './source.js';
|
||||
|
||||
const respond = (payload) =>
|
||||
createShipwrecksSource({ fetchImpl: async () => Response.json(payload) });
|
||||
|
||||
test('the snapshot keeps valid source ages and drops malformed ones', async () => {
|
||||
const record = { id: 'a', source: 'emodnet', lat: 50, lon: -1 };
|
||||
const good = await respond({
|
||||
records: [record],
|
||||
version: 'v',
|
||||
ages: { emodnet: 0, wikidata: 3600 },
|
||||
maxAgeMs: 604800000,
|
||||
}).getSnapshot();
|
||||
assert.deepEqual(good.ages, { emodnet: 0, wikidata: 3600 });
|
||||
assert.equal(good.maxAgeMs, 604800000);
|
||||
assert.equal(good.version, 'v');
|
||||
const bad = await respond({
|
||||
records: [record],
|
||||
ages: { emodnet: -5, wikidata: 'x' },
|
||||
maxAgeMs: Number.NaN,
|
||||
}).getSnapshot();
|
||||
assert.deepEqual(bad.ages, { emodnet: null, wikidata: null });
|
||||
assert.equal(bad.maxAgeMs, null);
|
||||
assert.equal(bad.version, null);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { createWindSource } from '../layers/wind/source.js';
|
||||
import { createFirmsSource } from '../layers/firms/source.js';
|
||||
import { createGhostMapsSource } from '../layers/ghostMaps/source.js';
|
||||
import { createNewsSource } from '../layers/news/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';
|
||||
@@ -65,6 +66,7 @@ export function createStandaloneLayerSources(
|
||||
// without it, answers "not configured", which the layer shows as
|
||||
// unavailable without retrying.
|
||||
news: createNewsSource(),
|
||||
shipwrecks: createShipwrecksSource(),
|
||||
wind: createWindSource(),
|
||||
weather: createWeatherSource(),
|
||||
cyclones: createCycloneSource(),
|
||||
|
||||
@@ -39,6 +39,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
||||
'wildlife-sightings': msg('Wildlife Sightings'),
|
||||
's2-ghostmaps': msg('S2 GhostMaps'),
|
||||
news: msg('News'),
|
||||
shipwrecks: msg('Shipwrecks'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
|
||||
+22
-2
@@ -82,6 +82,7 @@ const PANEL_GROUPS = [
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'shipwrecks',
|
||||
'news',
|
||||
],
|
||||
},
|
||||
@@ -462,6 +463,8 @@ export class LayerPanel {
|
||||
this._selectListItem(layerId, itemId, generation),
|
||||
onSlider: (layerId, sliderId, value) =>
|
||||
this._moveSlider(layerId, sliderId, value, generation),
|
||||
onRange: (layerId, rangeId, end, value) =>
|
||||
this._pickRangeEnd(layerId, rangeId, end, value, generation),
|
||||
}),
|
||||
id === null ? this._toggleContainer : this._groupContainers.get(id),
|
||||
);
|
||||
@@ -491,7 +494,7 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer's row chips, one-of-many switch, slider, color legend, info line
|
||||
* A layer's row chips, one-of-many switch, slider, start/end pickers, color legend, info line
|
||||
* and ordered list. The block
|
||||
* is hidden while the layer is off (or while a dependency owner has
|
||||
* surrendered it), so a quiet row stays quiet. Chips and list items are
|
||||
@@ -518,6 +521,7 @@ export class LayerPanel {
|
||||
const infoText = controls?.info ? String(controls.info) : '';
|
||||
const segmented = controls?.segmented || null;
|
||||
const slider = controls?.slider || null;
|
||||
const range = controls?.range || null;
|
||||
// The info line, once shown, stays in place and hides when empty.
|
||||
if (infoText) row.info = true;
|
||||
return {
|
||||
@@ -526,10 +530,12 @@ export class LayerPanel {
|
||||
legend.length === 0 &&
|
||||
!infoText &&
|
||||
!segmented &&
|
||||
!slider,
|
||||
!slider &&
|
||||
!range,
|
||||
chips,
|
||||
segmented,
|
||||
slider,
|
||||
range,
|
||||
legend,
|
||||
info: row.info
|
||||
? { text: infoText, title: controls?.infoTitle || '' }
|
||||
@@ -594,6 +600,20 @@ export class LayerPanel {
|
||||
this.setLayerParams(id, slider.params(value), { origin: 'user' });
|
||||
}
|
||||
|
||||
_pickRangeEnd(id, rangeId, end, value, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const range = this._rowControlsFor(id)?.range;
|
||||
if (
|
||||
!range ||
|
||||
range.id !== rangeId ||
|
||||
(end !== 'from' && end !== 'to') ||
|
||||
typeof range.params !== 'function' ||
|
||||
!this.isEnabled(id)
|
||||
)
|
||||
return;
|
||||
this.setLayerParams(id, range.params(end, value), { origin: 'user' });
|
||||
}
|
||||
|
||||
_selectListItem(id, itemId, generation = this._generation) {
|
||||
if (this._destroyed || this._generation !== generation) return;
|
||||
const item = this._rowControlsFor(id)?.list?.items?.find(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import RailChips from '../rail/RailChips.vue';
|
||||
import RailRowList from '../rail/RailRowList.vue';
|
||||
import RowRange from './RowRange.vue';
|
||||
import RowSlider from './RowSlider.vue';
|
||||
import RowSwitch from './RowSwitch.vue';
|
||||
import RowText from './RowText.vue';
|
||||
@@ -15,6 +16,7 @@ defineProps({
|
||||
onChip: { type: Function, default: () => {} },
|
||||
onListItem: { type: Function, default: () => {} },
|
||||
onSlider: { type: Function, default: () => {} },
|
||||
onRange: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -68,6 +70,13 @@ defineProps({
|
||||
:on-input="
|
||||
(value) => onSlider(item.id, item.controls.slider.id, value)
|
||||
"
|
||||
/><RowRange
|
||||
v-if="item.controls.range"
|
||||
:range="item.controls.range"
|
||||
:on-select="
|
||||
(end, value) =>
|
||||
onRange(item.id, item.controls.range.id, end, value)
|
||||
"
|
||||
/><span
|
||||
v-for="(entry, index) in item.controls.legend"
|
||||
:key="index"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
// A row's start and end pickers on one line (e.g. the Shipwrecks period).
|
||||
// Both ends share one option list; `onSelect` reports which end moved
|
||||
// ('from' or 'to') and the chosen option value.
|
||||
defineProps({
|
||||
range: { type: Object, required: true },
|
||||
onSelect: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-toggle-range" :data-range-id="range.id"
|
||||
><div class="data-toggle-slider-head"
|
||||
><span class="data-toggle-slider-label">{{ range.label }}</span
|
||||
><span class="data-toggle-slider-value gev-slider-value">{{
|
||||
range.valueText
|
||||
}}</span></div
|
||||
><div class="data-toggle-range-ends"
|
||||
><label
|
||||
v-for="end in ['from', 'to']"
|
||||
:key="end"
|
||||
class="data-toggle-range-end"
|
||||
><span class="data-toggle-range-caption">{{ range[end].label }}</span
|
||||
><select
|
||||
class="data-toggle-range-select"
|
||||
:data-range-end="end"
|
||||
:aria-label="range[end].ariaLabel || undefined"
|
||||
:value="String(range[end].value)"
|
||||
@change="onSelect(end, $event.target.value)"
|
||||
><option
|
||||
v-for="option in range.options"
|
||||
:key="option.value"
|
||||
:value="String(option.value)"
|
||||
>{{ option.label }}</option
|
||||
></select
|
||||
></label
|
||||
></div
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
@@ -159,3 +159,62 @@ test('a row switch and slider render their markup and report choices', () => {
|
||||
assert.equal(segments[1].classList.contains('active'), true);
|
||||
render(null, container);
|
||||
});
|
||||
|
||||
test('a row range renders start and end pickers and reports which end moved', () => {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const picks = [];
|
||||
const item = {
|
||||
...row(false),
|
||||
key: 'shipwrecks',
|
||||
id: 'shipwrecks',
|
||||
controls: {
|
||||
hidden: false,
|
||||
chips: [],
|
||||
segmented: null,
|
||||
slider: null,
|
||||
range: {
|
||||
id: 'period',
|
||||
label: 'Period',
|
||||
valueText: '2010–2024',
|
||||
from: { label: 'From', ariaLabel: 'First year', value: 2010 },
|
||||
to: { label: 'To', ariaLabel: 'Last year', value: 2024 },
|
||||
options: [2024, 2010, 2007].map((year) => ({
|
||||
value: year,
|
||||
label: String(year),
|
||||
})),
|
||||
},
|
||||
legend: [],
|
||||
info: null,
|
||||
list: null,
|
||||
},
|
||||
};
|
||||
render(
|
||||
h(LayerRows, {
|
||||
items: [item],
|
||||
onRange: (id, rangeId, end, value) =>
|
||||
picks.push([id, rangeId, end, value]),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const block = container.querySelector('.data-toggle-range');
|
||||
assert.equal(block.dataset.rangeId, 'period');
|
||||
assert.equal(
|
||||
block.querySelector('.data-toggle-slider-value').textContent,
|
||||
'2010–2024',
|
||||
);
|
||||
const [from, to] = block.querySelectorAll('select.data-toggle-range-select');
|
||||
assert.equal(from.getAttribute('aria-label'), 'First year');
|
||||
assert.equal(from.value, '2010');
|
||||
assert.equal(to.value, '2024');
|
||||
assert.deepEqual(
|
||||
[...block.querySelectorAll('.data-toggle-range-caption')].map(
|
||||
(caption) => caption.textContent,
|
||||
),
|
||||
['From', 'To'],
|
||||
);
|
||||
to.value = '2007';
|
||||
to.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
assert.deepEqual(picks, [['shipwrecks', 'period', 'to', '2007']]);
|
||||
render(null, container);
|
||||
});
|
||||
@@ -1092,6 +1092,43 @@ html:not([lang='en']) .data-row-list-text {
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
/* Optional start and end pickers on one line (e.g. a layer's period). The
|
||||
shared field style paints each select. */
|
||||
.data-toggle-range {
|
||||
flex-basis: 100%;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.data-toggle-range-ends {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-toggle-range-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-toggle-range-caption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-toggle-range-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
font: 10px/1 var(--font-mono);
|
||||
}
|
||||
|
||||
/* Optional per-layer quantitative slider (Active Fires minimum FRP). The
|
||||
track is painted by the layer (`--slider-track`, a stepped color ramp), so
|
||||
the slider doubles as the marker legend; ticks sit under their thresholds. */
|
||||
|
||||
@@ -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',
|
||||
news: 'browsed in the News panel; voice tools are a later step',
|
||||
'wildlife-sightings':
|
||||
'community observation layer; no room in the prompt budget',
|
||||
|
||||
Reference in new issue
Block a user