feat(layers): add shipwrecks layer (EMODnet + Wikidata)

A new Events layer maps shipwrecks with a card per wreck:
- EMODnet Human Activities heritage shipwrecks (about 6,500 in European
  waters, CC BY 4.0), skipping rows taken from wrecksite.eu.
- Wikidata shipwrecks with a Wikipedia article plus sinking events
  (CC0); an event folds into its ship for the date and death toll.
- A same-origin /api/shipwrecks proxy refreshes once a day and serves
  the last snapshot as stale for up to a week.

Markers are a boat icon coloured by era and sized by deaths; four era
chips filter the layer. The card follows the air-accidents layout
(deaths first). Text that names people is dropped; victims appear as
a count only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Itallo BrandãoandClaude Opus 5.5 committed 2026-11-05 04:42:59 -03:00
1 parent 7d8b80db0a
commit dcd0f2cccd
41 files changed
+2168 -73

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
import * as Cesium from 'cesium';
import { formatNumber, msg, t, tn } from '../../i18n/index.js';
import { createHorizonCull } from '../streetLevel/view.js';
import { ERA_COLORS, ERA_LABELS, describeShipwreck } from './cards.js';
import { SHIPWRECK_ERAS, eraOf, normalizeEras } from './eras.js';
export * from './records.js';
export * from './cards.js';
export * from './eras.js';
export { createShipwrecksSource } from './source.js';
export const SHIPWRECKS_LAYER_ID = 'shipwrecks';
export const SHIPWRECKS_OVERLAY_SOURCE_ID = 'shipwrecks';
const PICK_PREFIX = 'shipwreck:';
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
const MAX_CARD_LINES = 14;
/**
* Marker size in pixels: grows with the death toll, as on the air-accidents
* layer, from 16 px (no deaths known) to 36 px (about a thousand or more).
*/
export function markerPixelSize(casualties) {
const n = Number.isFinite(casualties) && casualties > 0 ? casualties : 0;
return Math.round(16 + Math.min(20, Math.log2(1 + n) * 2));
}
/**
* A round marker in the era's colour with a sinking hull and mast in the
* middle, as an SVG data URL.
* @param {string} color CSS colour.
* @returns {string}
*/
export function wreckMarkerImage(color) {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
`<circle cx="18" cy="18" r="16" fill="${color}" stroke="#ffffff" stroke-width="2"/>` +
`<path d="M8 21h20l-3.5 5h-13z" fill="#0b1420"/>` +
`<path d="M17 8v12M17 9l6 7h-6" fill="none" stroke="#0b1420" stroke-width="2.2" stroke-linejoin="round"/>` +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/**
* Shipwrecks: EMODnet's European heritage wrecks and Wikidata's notable
* wrecks worldwide, one marker each, with a card and era chips.
*/
export function createShipwrecksLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
openExternal = null,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Shipwrecks require a snapshot source');
let _viewer = null;
let _enabled = false;
let _dataSource = null;
let _request = null;
let _records = new Map();
let _pins = [];
let _snapshot = null;
let _lastError = null;
let _clickHandler = null;
let _rowListener = null;
let _selectedId = null;
let _selectedCardId = null;
let _selectedLink = null;
let _eras = new Set(SHIPWRECK_ERAS);
const _images = Object.fromEntries(
SHIPWRECK_ERAS.map((era) => [era, wreckMarkerImage(ERA_COLORS[era])]),
);
const render = () => _viewer?.scene?.requestRender?.();
const notifyRow = () => _rowListener?.();
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
// Markers skip the depth test so terrain does not swallow them; the
// horizon cull hides the far side of the planet instead.
const _horizon = createHorizonCull({
getViewer: () => _viewer,
items: () => _pins,
onChange: render,
});
/** Shown: its era chip is on. */
function visible(record) {
return Boolean(record) && _eras.has(eraOf(record));
}
function applyPin(pin) {
pin.entity.show = pin.inFront && visible(pin.record);
}
/** Re-apply the era chips to every marker and the open card. */
function applyFilter() {
for (const pin of _pins) applyPin(pin);
if (_selectedId && !visible(_records.get(_selectedId))) clearSelection();
notifyRow();
render();
}
function rebuild(records) {
_records = new Map(records.map((record) => [record.id, record]));
_pins = [];
if (!_dataSource) return;
_dataSource.entities.suspendEvents();
_dataSource.entities.removeAll();
for (const record of records) {
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
const entity = _dataSource.entities.add({
id: `${PICK_PREFIX}${record.id}`,
position,
billboard: {
image: _images[eraOf(record)],
width: markerPixelSize(record.casualties),
height: markerPixelSize(record.casualties),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
// Bigger toll, drawn on top of smaller neighbours.
eyeOffset: new Cesium.Cartesian3(
0,
0,
-Math.min(record.casualties || 0, 5000),
),
},
});
const pin = {
entity,
record,
position,
inFront: true,
get show() {
return this.inFront;
},
set show(value) {
this.inFront = value;
applyPin(this);
},
};
applyPin(pin);
_pins.push(pin);
}
_dataSource.entities.resumeEvents();
_horizon.update();
if (_selectedId && !_records.has(_selectedId)) clearSelection();
else if (_selectedId) publishCard();
render();
}
// ---- selection card ----------------------------------------------------
function publishCard() {
if (!canSelect()) return;
const record = _selectedId ? _records.get(_selectedId) : null;
if (!visible(record)) {
clearSelection();
return;
}
const { title, details, link } = describeShipwreck(record);
_selectedLink = link && openExternal ? link : null;
const card = {
id: `shipwreck-card:${record.id}`,
selected: true,
interactive: Boolean(_selectedLink),
title,
details: details.slice(0, MAX_CARD_LINES),
accent: ERA_COLORS[eraOf(record)],
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
stateless: true,
horizonCull: true,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
};
if (card.interactive) {
const target = _selectedLink;
card.accessibilityLabel = t('Open the source for {title}', { title });
card.activate = () => {
openExternal(target);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
SHIPWRECKS_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
overlayHost.setVisible?.(SHIPWRECKS_OVERLAY_SOURCE_ID, true);
}
function clearSelection() {
_selectedId = null;
_selectedCardId = null;
_selectedLink = null;
overlayHost?.setEntries(
SHIPWRECKS_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
}
function pickedRecordId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const id = pickId.slice(PICK_PREFIX.length);
return _records.has(id) ? id : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
picking.registerPickOwner?.(
SHIPWRECKS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
);
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: SHIPWRECKS_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const id = picked ? pickedRecordId(picked) : null;
if (id) {
_selectedId = id;
publishCard();
render();
return;
}
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(SHIPWRECKS_LAYER_ID, pickId))
return;
}
if (_selectedId) {
clearSelection();
render();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
_clickHandler?.destroy();
_clickHandler = null;
picking?.unregisterPickOwner?.(SHIPWRECKS_LAYER_ID);
}
function toggleEra(era) {
if (_eras.has(era)) _eras.delete(era);
else _eras.add(era);
applyFilter();
}
function countOf(era) {
let count = 0;
for (const record of _records.values()) if (eraOf(record) === era) count++;
return count;
}
const layer = {
id: SHIPWRECKS_LAYER_ID,
name: 'Shipwrecks',
icon: '⚓',
source: 'EMODnet · Wikidata',
// The proxy refreshes once a day; this only notices a new snapshot.
updateInterval: 6 * 60 * 60 * 1000,
init(viewer) {
if (_viewer) throw new Error('Shipwrecks layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource(SHIPWRECKS_LAYER_ID);
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
installClickHandler();
if (_pins.length) _horizon.update();
},
disable() {
_enabled = false;
_request?.abort();
_request = null;
if (_dataSource) _dataSource.show = false;
removeClickHandler();
clearSelection();
_horizon.stop();
render();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const snapshot = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
_snapshot = snapshot;
_lastError =
snapshot.sources.emodnet && snapshot.sources.wikidata
? null
: snapshot.sources.emodnet
? msg('Wikidata unavailable; showing EMODnet only')
: msg('EMODnet unavailable; showing Wikidata only');
rebuild(snapshot.records);
notifyRow();
return true;
} catch (error) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:Shipwrecks] Fetch error:', error);
_lastError = error?.message || msg('Shipwrecks unavailable');
notifyRow();
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
layer.disable();
if (_dataSource) {
viewer?.dataSources?.remove(_dataSource, true);
_dataSource = null;
}
_records = new Map();
_pins = [];
_snapshot = null;
_viewer = null;
_rowListener = null;
},
/** The era filter: `eras` lists the era chips that are on. */
getParams() {
return { eras: SHIPWRECK_ERAS.filter((era) => _eras.has(era)) };
},
setParams(params = {}) {
if (!('eras' in params)) return true;
const next = normalizeEras(params.eras, _eras);
if (next.size === _eras.size && [...next].every((era) => _eras.has(era)))
return true;
_eras = next;
applyFilter();
return true;
},
setRowControlsListener(listener) {
_rowListener = listener;
},
getRowControls() {
return {
chips: SHIPWRECK_ERAS.map((era) => ({
id: era,
label: t(ERA_LABELS[era]),
active: _eras.has(era),
color: ERA_COLORS[era],
title: tn(countOf(era), '{count} wreck', '{count} wrecks', {
count: formatNumber(countOf(era)),
}),
onClick: () => toggleEra(era),
})),
legend: [],
info: t('EMODnet (European waters) · Wikidata (worldwide)'),
infoTitle: t(
'European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.',
),
};
},
getStats() {
let shown = 0;
for (const record of _records.values()) if (visible(record)) shown++;
return {
count: shown,
lastUpdate: _snapshot?.fetchedAt ?? null,
stale: _snapshot?.stale === true,
error: _lastError,
};
},
/** Loaded records as plain JSON-safe objects, for tools and QA. */
getAnalystRecords(maxCount = 2000) {
if (!_enabled) return [];
const out = [];
for (const record of _records.values()) {
if (out.length >= maxCount) break;
if (!visible(record)) continue;
out.push({
id: record.id,
source: record.source,
name: record.name,
vesselType: record.vesselType,
year: record.year,
era: eraOf(record),
lat: record.lat,
lon: record.lon,
depth: record.depth,
casualties: record.casualties,
});
}
return out;
},
};
return layer;
}