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; /** * 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 = `` + `` + `` + `` + ``; 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, // 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 _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); } function countOf(era) { let count = 0; for (const record of _records.values()) if (eraOf(record) === era && inPeriod(record, _range)) count++; return count; } /** 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}`; const options = []; for (let year = _extent.max; year >= _extent.min; year--) options.push({ 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 = snapshot.version !== null && snapshot.version === _snapshot?.version && _pins.length > 0; _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'); 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'); 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; _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() { return { chips: [], ...periodControls(), // The colours are a legend: when the ship sank. legend: SHIPWRECK_ERAS.map((era) => ({ label: t(ERA_LABELS[era]), count: countOf(era), color: ERA_COLORS[era], blurb: tn(countOf(era), '{count} wreck', '{count} wrecks', { count: formatNumber(countOf(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; }