From 586ed4f8ea7f89ff24f82635419cee2e618b8856 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:04 +0100 Subject: [PATCH 01/86] fix(tools): bound untrusted display text at output boundaries --- docs/CURRENT-STATE.md | 7 ++++++ scripts/package-boundaries.json | 6 +++++ src/data/alprCameras.test.mjs | 21 +++++++++++++++++ src/data/displayText.js | 12 ++++++++++ src/layers/alpr/records.js | 3 ++- src/tools/queries/surveillance.js | 11 +++++---- src/tools/queries/surveillance.test.mjs | 31 +++++++++++++++++++++++++ src/voice/gevActions.js | 19 +++++++-------- src/voice/gevActions.test.mjs | 13 +++++++++++ 9 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 src/data/displayText.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..b3a48f66e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +External ALPR tags, surveillance-tool display fields, voice context text and +compact analyst strings collapse controls and whitespace before a final +180-character cap (scene labels retain their 120-character cap). Empty ALPR +values remain null and Unicode text is preserved without splitting a surrogate +pair. These fields remain untrusted source data in structured tool output; text +normalization does not establish trust or eliminate prompt injection. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..859b030e7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1450,6 +1450,7 @@ "src/layers/alpr/overlay.js", "src/layers/alpr/source.js", "src/data/inputOwnership.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/data/tomtomTiles.js", "src/layers/alpr/tileRecords.js", @@ -2228,6 +2229,7 @@ "src/ui/visualSettings.js", "src/voice/commands.js", "src/voice/control.js", + "src/data/displayText.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", "src/voice/speech.js", @@ -2914,6 +2916,7 @@ "src/search/coordinateParser.js", "src/search/presetGeocoder.js", "src/app/localGeojsonServices.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", "src/app/layers/perimeters.js", @@ -3407,6 +3410,7 @@ "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/alpr/source.js", "src/data/tomtomTiles.js", @@ -3817,6 +3821,7 @@ "src/i18n/marker.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", + "src/data/displayText.js", "src/tools/queries/surveillance.js", "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", @@ -3923,6 +3928,7 @@ "src/layers/submarineCables/bundledSource.js", "src/layers/alpr/source.js", "src/layers/alpr/policy.js", + "src/data/displayText.js", "src/layers/alpr/records.js", "src/layers/alpr/tileRecords.js", "src/sources/infrastructureData.js", diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..e68b11da3 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -1493,3 +1493,24 @@ test('a repeated move inside an outstanding query keeps the displayed marker set h.restore(); } }); + +test('ALPR display tags collapse controls and cap final text without erasing Unicode', () => { + const record = normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { + 'surveillance:type': 'ALPR', + operator: ' ลžehir\n\u0000 PD ', + manufacturer: '๐Ÿ˜€'.repeat(200), + 'camera:type': null, + 'surveillance:zone': '\t traffic zone ', + }, + }); + assert.equal(record.operator, 'ลžehir PD'); + assert.equal(record.manufacturer.length, 180); + assert.equal(record.manufacturer, '๐Ÿ˜€'.repeat(90)); + assert.equal(record.cameraType, null); + assert.equal(record.zone, 'traffic zone'); +}); diff --git a/src/data/displayText.js b/src/data/displayText.js new file mode 100644 index 000000000..1138c4cc0 --- /dev/null +++ b/src/data/displayText.js @@ -0,0 +1,12 @@ +/** Bounded, single-line external display text; it remains untrusted source data. */ +export function normalizeDisplayText(value, maxLength = 180) { + if (value == null || typeof value === 'object' || typeof value === 'function') + return ''; + return String(value) + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength) + .replace(/[\uD800-\uDBFF]$/, '') + .trim(); +} diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..d8d2ac104 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -1,7 +1,8 @@ +import { normalizeDisplayText } from '../../data/displayText.js'; import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js'; export function textTag(value) { - const t = String(value ?? '').trim(); + const t = normalizeDisplayText(value); return t || null; } diff --git a/src/tools/queries/surveillance.js b/src/tools/queries/surveillance.js index 06723007a..4ddafabc2 100644 --- a/src/tools/queries/surveillance.js +++ b/src/tools/queries/surveillance.js @@ -1,5 +1,6 @@ /** Automated license plate reader cameras mapped in OpenStreetMap. */ +import { normalizeDisplayText } from '../../data/displayText.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { @@ -69,12 +70,12 @@ export const findAlprCameras = defineTool({ id: camera.id, lat: round(point.lat, 6), lon: round(point.lon, 6), - operator: camera.operator || null, - manufacturer: camera.manufacturer || null, - camera_type: camera.cameraType || null, - zone: camera.zone || null, + operator: normalizeDisplayText(camera.operator) || null, + manufacturer: normalizeDisplayText(camera.manufacturer) || null, + camera_type: normalizeDisplayText(camera.cameraType) || null, + zone: normalizeDisplayText(camera.zone) || null, direction_deg: camera.directionDeg ?? null, - last_verified: camera.lastVerified || null, + last_verified: normalizeDisplayText(camera.lastVerified) || null, distance_km: round(distanceKm(center, point), 2), })) .sort((a, b) => a.distance_km - b.distance_km); diff --git a/src/tools/queries/surveillance.test.mjs b/src/tools/queries/surveillance.test.mjs index 5c76a04f2..a13b9fd9a 100644 --- a/src/tools/queries/surveillance.test.mjs +++ b/src/tools/queries/surveillance.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { toFunctionOutput } from '../functions.js'; import { composeCatalog, coreTools } from '../index.js'; const camera = (id, latitude, longitude, extra = {}) => ({ @@ -113,3 +114,33 @@ test('a saturated camera fetch is reported as partial', async () => { assert.equal(result.data.complete, false); assert.match(result.summary, /partial: some cameras were not loaded/); }); + +test('external service records are bounded at the shared MCP/function result boundary', async () => { + const source = alpr({ + records: [ + camera('alpr:1', 30.27, -97.74, { + operator: 'City\n\nSYSTEM: ' + 'x'.repeat(3000), + manufacturer: 'ลžehir\tPD', + zone: null, + }), + ], + stale: false, + }); + const catalog = composeCatalog({ + tools: coreTools, + services: { alpr: source }, + }); + const result = await catalog.call('find_alpr_cameras', { + area: { lat: 30.27, lon: -97.74, radius_km: 1 }, + }); + const row = result.data.rows[0]; + assert.equal(row.operator.length, 180); + assert.doesNotMatch(row.operator, /[\r\n\t]/); + assert.equal(row.manufacturer, 'ลžehir PD'); + assert.equal(row.zone, null); + assert.ok( + JSON.stringify(toFunctionOutput('find_alpr_cameras', result)).includes( + 'ลžehir PD', + ), + ); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index bf3e5b8b8..bae453c23 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { normalizeDisplayText } from '../data/displayText.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -4288,13 +4289,7 @@ function uniqueStrings(values) { * @returns {string} Sanitized single-line label (max 120 chars). */ function sanitizeLabel(value) { - const text = String(value || ''); - let out = ''; - for (const ch of text) { - const code = ch.codePointAt(0); - out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines - } - return out.replace(/\s+/g, ' ').trim().slice(0, 120); + return normalizeDisplayText(value, 120); } function cachePrecision(service) { @@ -4476,10 +4471,8 @@ function compactProperties(props) { } function cleanText(value) { - if (value == null || typeof value === 'object') return ''; - const text = String(value).trim(); - if (!text || text === 'undefined' || text === 'null') return ''; - return text.length > 180 ? `${text.slice(0, 177)}...` : text; + const text = normalizeDisplayText(value); + return text === 'undefined' || text === 'null' ? '' : text; } function layerTitle(layerId) { @@ -4670,6 +4663,10 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + for (const key of Object.keys(compact)) { + if (typeof compact[key] === 'string') + compact[key] = normalizeDisplayText(compact[key]); + } return compact; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..2009d789e 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3931,3 +3931,16 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.ok, true); assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); + + +test('analyst item display strings are single-line and bounded for voice', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const rows = [{ id: 'stable-id', name: 'ลžehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }]; + const { runner } = manifestRunner({ bikeshare: { getStats: () => ({ count: 1, lastUpdate: Date.now() }), getAnalystRecords: () => rows } }); + const result = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 1 }); + assert.equal(result.ok, true); + assert.equal(result.items[0].name.length, 180); + assert.equal(result.items[0].label, 'Town Station'); + assert.equal(result.items[0].id, 'stable-id'); + for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/); +}); From 3685c416f3d0e13a2e19e9e70e777d3723b323cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:41 +0100 Subject: [PATCH 02/86] docs: record text fix --- changelog.d/1280-text-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1280-text-issue-fix.md diff --git a/changelog.d/1280-text-issue-fix.md b/changelog.d/1280-text-issue-fix.md new file mode 100644 index 000000000..d13f3f9cc --- /dev/null +++ b/changelog.d/1280-text-issue-fix.md @@ -0,0 +1 @@ +- Bound untrusted display text at output boundaries (asturksever, #1280). From cbca6a228d83ff13a19a9f20bd9003a4eefd3d3a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:31:18 +0100 Subject: [PATCH 03/86] fix(voice): keep canonical analyst IDs out of display-text normalization compactAnalystItem normalized every string field, including `id`. Bikeshare uses the raw station name as its ID, so a station with repeated spaces or a name over 180 code units was registered as a referent under a different key and a numbered fly_to_location reported it as no longer loaded. Normalize display fields only; layerKey, id, icao24, mmsi, noradId, registration and callsign pass through as the layer reports them. Adds an analyst_query -> referent -> fly_to_location test for repeated-space and long station names, and documents the rule in docs/CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 +++++-- src/voice/gevActions.js | 20 +++++++++++++++++- src/voice/gevActions.test.mjs | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 721caabd4..0e875c1ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -13,8 +13,12 @@ External ALPR tags, surveillance-tool display fields, voice context text and compact analyst strings collapse controls and whitespace before a final 180-character cap (scene labels retain their 120-character cap). Empty ALPR values remain null and Unicode text is preserved without splitting a surrogate -pair. These fields remain untrusted source data in structured tool output; text -normalization does not establish trust or eliminate prompt injection. +pair. Only display fields are normalized: canonical analyst identifiers +(`id`, `layerKey`, `icao24`, `mmsi`, `noradId`, `registration`, `callsign`) +pass through unchanged, because bikeshare uses the raw station name as its ID +and numbered voice references resolve by that key. These fields remain +untrusted source data in structured tool output; text normalization does not +establish trust or eliminate prompt injection. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 308736ed5..71610992c 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4570,6 +4570,21 @@ const ANALYST_IDENTITY_FIELDS = Object.freeze([ 'noradId', ]); +/** + * Analyst item keys that identify a record rather than describe it: the + * referent registry, track_entity and locateLayerEntity match on them, so + * display-text normalization must leave them byte-for-byte unchanged. + */ +const ANALYST_CANONICAL_FIELDS = new Set([ + 'layerKey', + 'id', + 'icao24', + 'mmsi', + 'noradId', + 'registration', + 'callsign', +]); + /** * Compact one analyst record for the voice payload: identity, position (so a * follow-up can fly to it), distance, and that layer's manifest fields with @@ -4607,8 +4622,11 @@ function compactAnalystItem(r) { } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; + // Display text only: canonical keys are lookup identities (bikeshare uses + // the raw station name as its id), so the referent registry and + // locateLayerEntity must see them exactly as the layer reports them. for (const key of Object.keys(compact)) { - if (typeof compact[key] === 'string') + if (typeof compact[key] === 'string' && !ANALYST_CANONICAL_FIELDS.has(key)) compact[key] = normalizeDisplayText(compact[key]); } return compact; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 2009d789e..5367bd2f3 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3944,3 +3944,43 @@ test('analyst item display strings are single-line and bounded for voice', async assert.equal(result.items[0].id, 'stable-id'); for (const value of Object.values(result.items[0])) if (typeof value === 'string') assert.doesNotMatch(value, /[\r\n\t]/); }); + +test('analyst_query keeps canonical IDs raw so numbered references still fly to the station', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + const referents = createReferentRegistry(); + const longName = 'Station ' + 'รฉ'.repeat(200); + const spaced = 'Congress Ave & 2nd'; + const stations = [ + { id: spaced, name: spaced, lat: 30.2672, lon: -97.7431 }, + { id: longName, name: longName, lat: 30.2711, lon: -97.7437 }, + ]; + const bikeshare = { getStats: () => ({ count: stations.length, lastUpdate: Date.now() }), getAnalystRecords: () => stations }; + const runner = createActionRunner({ + viewer, styleManager, + dataManager: { + layers: new Map([['bikeshare', { module: bikeshare }]]), + isEnabled: () => true, + getAll: () => [{ id: 'bikeshare', name: 'bikeshare', enabled: true, stats: bikeshare.getStats() }], + }, + deixis: { referents }, + }); + const listed = await runner('analyst_query', { layers: ['bikeshare'], scope: { kind: 'anywhere' }, limit: 2 }); + assert.equal(listed.ok, true); + const bySpaced = listed.items.find((item) => item.id === spaced); + const byLong = listed.items.find((item) => item.id === longName); + assert.ok(bySpaced && byLong, 'compact items keep the raw station-name IDs'); + assert.equal(bySpaced.name, 'Congress Ave & 2nd', 'display name is still normalized'); + assert.equal(byLong.name.length, 180, 'display name is still bounded'); + assert.equal(byLong.id.length, longName.length); + const registered = referents.list(); + assert.equal(registered.length, 2, 'the analyst result became the numbered list'); + for (const ref of registered) { + const station = stations.find((row) => row.id === ref.id); + assert.ok(station, `referent ${ref.n} keeps the canonical ID`); + const flown = await runner('fly_to_location', { referent: ref.n }); + assert.equal(flown.ok, true, `referent ${ref.n} resolves instead of "no longer loaded"`); + assert.equal(flown.latitude, station.lat); + assert.equal(flown.longitude, station.lon); + } +}); From 6172be39955483d32289e7262f8f8232fd41e9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:39:15 -0300 Subject: [PATCH 04/86] feat(transit): give vehicle icons a readable front, and none without a course At the ~20 px the fleet renders at, the bus, tram, metro and unknown glyphs were near-symmetric boxes: rotated along the course, nothing showed which end was the front. Each now has a front built into its outline (an arrow-like nose for the bus, a pointed cab for the tram, a chamfered cab for the metro), so it survives the tint, the halo and the sensor profiles, which keep only the outline. A vehicle with no known course sits at rotation 0, where a pointed glyph would claim it is heading north. Those vehicles, fleet and selected alike, now get a symmetric outline (rail and ferry included) and switch to the pointed one once a course arrives. The raster cache doubles to a fixed 72 variants. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-transit.mjs | 2 +- src/data/transitIcons.js | 107 ++++++++++++++---- src/data/transitIcons.test.mjs | 192 +++++++++++++++++++++----------- src/layers/transit/rendering.js | 13 +++ 4 files changed, 225 insertions(+), 89 deletions(-) diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 49a25fd18..32bfb596a 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -801,7 +801,7 @@ try { ); check( `${preset.name}: the icon raster cache stays bounded`, - styling.iconCacheSize <= 6 * 2 * 3, + styling.iconCacheSize <= 6 * 2 * 3 * 2, `${styling.iconCacheSize} rasters`, ); } diff --git a/src/data/transitIcons.js b/src/data/transitIcons.js index ec480530d..c6ad963e1 100644 --- a/src/data/transitIcons.js +++ b/src/data/transitIcons.js @@ -41,33 +41,46 @@ const DETAIL = /** * Each body is drawn in a centred frame (origin 0,0 = glyph centre), front * toward -Y, in the 96-unit space (half-extent up to ~45). + * + * Two sets. `HEADING_BODIES` is what a vehicle with a known course wears: every + * outline has a front that cannot be mistaken for its back at fleet size โ€” a + * nose, a bow or a chamfered cab โ€” because at twenty pixels a bus drawn as a + * near-symmetric box gives no hint which way it is going, and a hairline + * windscreen is far below one pixel. The front is part of the OUTLINE, not + * interior ink, so it survives the tint, the halo (which strokes the outline) + * and the sensor profiles (which keep only the outline). + * + * `NEUTRAL_BODIES` is what a vehicle with NO known course wears. Its billboard + * sits at rotation 0, so a pointed glyph would claim it is heading north; these + * outlines are the same footprint, symmetric end to end, and claim nothing. */ -const BODIES = { - // โ”€โ”€ Bus: short, blunt, square-shouldered. The shape everything else reads - // against, and the one most people will see most often. +const HEADING_BODIES = { + // โ”€โ”€ Bus: short, broad, square-shouldered behind an arrow-like nose. The + // shape everything else reads against, and the one most people will see + // most often, so it carries the boldest front. bus: ` - - + `, - // โ”€โ”€ Tram / light rail: longer and slimmer than a bus, with the two - // articulation joints that make a modern low-floor tram unmistakable. + // โ”€โ”€ Tram / light rail: longer and slimmer than a bus, a short pointed cab + // and the two articulation joints of a modern low-floor tram. tram: ` - - `, + `, - // โ”€โ”€ Metro / subway: squared off at both ends, wider than a tram, a single - // cab line at the front. Reads as rolling stock, not a road vehicle. + // โ”€โ”€ Metro / subway: squared off and wider than a tram, the cab end + // chamfered to a narrow flat face; the rear stays square. subway: ` - - + `, @@ -90,8 +103,49 @@ const BODIES = { fill="white" ${STROKE}/> `, - // โ”€โ”€ Unknown: a plain rounded marker. Deliberately featureless, so an + // โ”€โ”€ Unknown: a plain marker with a nose and nothing else, so an // unclassified vehicle never pretends to be a mode it is not. + unknown: ` + `, +}; + +const NEUTRAL_BODIES = { + bus: ` + + + `, + + tram: ` + + + `, + + subway: ` + + + + `, + + rail: ` + + + `, + + ferry: ` + + `, + unknown: ` FLEET_RASTER_PX; px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX; const profile = transitStyleProfile( @@ -185,16 +242,18 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) { profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal'; const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected); const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx); - const key = `${k}:${selected ? 1 : 0}:${profile}`; + const heading = options.heading !== false; + const bodies = heading ? HEADING_BODIES : NEUTRAL_BODIES; + const key = `${k}:${selected ? 1 : 0}:${profile}:${heading ? 'h' : 'n'}`; let uri = _iconCache.get(key); if (!uri) { const size = VIEW + 2 * frame.pad; // Reuse the exact outer path. Sensors fill its interior without panel ink. - const silhouette = BODIES[k].match(/` - : BODIES[k]; + : bodies[k]; const halo = frame.units > 0 ? profile === 'mono' diff --git a/src/data/transitIcons.test.mjs b/src/data/transitIcons.test.mjs index 8f2571017..758b1d04b 100644 --- a/src/data/transitIcons.test.mjs +++ b/src/data/transitIcons.test.mjs @@ -62,39 +62,42 @@ test('the raster cache is bounded by kind, size and halo โ€” never by call count assert.ok(transitIconCacheSize() <= before + 3 + TRANSIT_ICON_KINDS.length); }); -test('all 36 variants retain the specified final halo at both display sizes', async () => { +test('all 72 variants retain the specified final halo at both display sizes', async () => { const { presetSpriteScale, presetSpriteOutlinePx } = await import('./transitPresetStyle.js'); for (const kind of TRANSIT_ICON_KINDS) { for (const selected of [false, true]) { - for (const style of ['normal', 'thermal', 'retro']) { - const display = - (selected ? 30 : 20) * presetSpriteScale(style, selected); - const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); - const units = Number( - svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], - ); - assert.ok( - Math.abs( - (units * display) / 192 - presetSpriteOutlinePx(style, selected), - ) < 0.001, - ); - const frame = haloFrame( - presetSpriteOutlinePx(style, selected), - display, - ); - assert.match( - svg, - new RegExp( - `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, - ), - ); - } + for (const style of ['normal', 'thermal', 'retro']) + for (const heading of [true, false]) { + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const svg = decode( + transitIcon(kind, selected ? 96 : 48, { style, heading }), + ); + const units = Number( + svg.match(/stroke-opacity="(?:0.95|1)" stroke-width="([\d.]+)"/)[1], + ); + assert.ok( + Math.abs( + (units * display) / 192 - presetSpriteOutlinePx(style, selected), + ) < 0.001, + ); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + assert.match( + svg, + new RegExp( + `width="${Math.round((selected ? 96 : 48) * frame.ratio)}"`, + ), + ); + } } } for (let i = 0; i < 100; i++) transitIcon('invalid', i, { haloScreenPx: i, screenPx: i }); - assert.equal(transitIconCacheSize(), 36); + assert.equal(transitIconCacheSize(), 72); }); test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => { @@ -133,47 +136,53 @@ test('every mono raster preserves the normal silhouette mask and aspect at CRT d return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) }; }; for (const selected of [false, true]) - for (const kind of TRANSIT_ICON_KINDS) { - const normal = await rasterBody( - decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })), - ); - for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { - const svg = decode(transitIcon(kind, selected ? 96 : 48, { style })); - const mono = await rasterBody(svg); - assert.deepEqual( - mono.mask, - normal.mask, - `${kind}/${style}: no envelope`, + for (const heading of [true, false]) + for (const kind of TRANSIT_ICON_KINDS) { + const normal = await rasterBody( + decode( + transitIcon(kind, selected ? 96 : 48, { style: 'normal', heading }), + ), ); - assert.ok( - Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, - `${kind}/${style}: aspect`, - ); - const display = - (selected ? 30 : 20) * presetSpriteScale(style, selected); - const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected); - assert.equal(display, crt); - const frame = haloFrame( - presetSpriteOutlinePx(style, selected), - display, - ); - const crtFrame = haloFrame( - presetSpriteOutlinePx('retro', selected), - crt, - ); - assert.equal( - (display * frame.ratio) / frame.ratio, - (crt * crtFrame.ratio) / crtFrame.ratio, - 'unpadded silhouette matches CRT; only external halo padding grows', - ); - assert.equal( - (svg.match(//); + for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) { + const svg = decode( + transitIcon(kind, selected ? 96 : 48, { style, heading }), + ); + const mono = await rasterBody(svg); + assert.deepEqual( + mono.mask, + normal.mask, + `${kind}/${style}: no envelope`, + ); + assert.ok( + Math.abs(mono.aspect / normal.aspect - 1) <= 0.05, + `${kind}/${style}: aspect`, + ); + const display = + (selected ? 30 : 20) * presetSpriteScale(style, selected); + const crt = + (selected ? 30 : 20) * presetSpriteScale('retro', selected); + assert.equal(display, crt); + const frame = haloFrame( + presetSpriteOutlinePx(style, selected), + display, + ); + const crtFrame = haloFrame( + presetSpriteOutlinePx('retro', selected), + crt, + ); + assert.equal( + (display * frame.ratio) / frame.ratio, + (crt * crtFrame.ratio) / crtFrame.ratio, + 'unpadded silhouette matches CRT; only external halo padding grows', + ); + assert.equal( + (svg.match(//); + } } - } }); test('sensor raster halo stays dark across its screen-space band on a white roof', async () => { @@ -238,3 +247,58 @@ test('sensor raster halo stays dark across its screen-space band on a white roof assert.ok(black.pass, JSON.stringify({ selected, black })); } }); + +test('a vehicle with a course shows its front; one without claims no direction', async () => { + const sharp = (await import('sharp')).default; + const outline = (svg) => svg.match(/ { + const { data, info } = await sharp( + Buffer.from( + ``, + ), + ) + .resize(20, 20) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const out = []; + for (let y = 0; y < info.height; y++) { + let sum = 0; + for (let x = 0; x < info.width; x++) + sum += data[(y * info.width + x) * 4 + 3] / 255; + out.push(sum); + } + return out; + }; + // How much the outline differs from itself turned end for end, in pixels. + const asymmetry = (r) => + r.reduce((a, v, i) => a + Math.abs(v - r[r.length - 1 - i]), 0); + for (const kind of TRANSIT_ICON_KINDS) { + const pointed = await rows(outline(decode(transitIcon(kind)))); + const neutral = await rows( + outline(decode(transitIcon(kind, 48, { heading: false }))), + ); + assert.ok( + asymmetry(pointed) >= 2.5, + `${kind}: front reads at 20 px (${asymmetry(pointed).toFixed(2)})`, + ); + assert.ok( + asymmetry(neutral) < 0.5, + `${kind}: no implied heading (${asymmetry(neutral).toFixed(2)})`, + ); + // The front is the narrow end: the two leading rows of ink carry less + // than the two trailing rows. + const inked = pointed.flatMap((v, i) => (v > 0.05 ? [i] : [])); + const first = inked[0]; + const last = inked[inked.length - 1]; + const lead = pointed[first] + pointed[first + 1]; + const trail = pointed[last] + pointed[last - 1]; + assert.ok(lead < trail, `${kind}: nose toward -Y`); + } + assert.notEqual( + transitIcon('bus'), + transitIcon('bus', 48, { heading: false }), + ); + assert.equal(transitIcon('bus', 48, { heading: true }), transitIcon('bus')); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 2309978a5..fafd9268b 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -232,11 +232,16 @@ export function createRendering({ state, services, parts }) { const displayPx = basePx * presetSpriteScale(style, selected); const frame = haloFrame(halo, displayPx); const px = displayPx * frame.ratio; + // A glyph with a front only when there is a course to point it along: + // without one the billboard sits at rotation 0 and a nose would say north. + const heading = Number.isFinite(entry.courseDeg); + entry.iconHeading = heading; marker.image = transitIcon( entry.mode, selected ? SELECTED_ICON_PX : undefined, { style, + heading, }, ); marker.width = px; @@ -252,6 +257,12 @@ export function createRendering({ state, services, parts }) { MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; } + /** Swap between the pointed and the neutral glyph when a course appears or is lost. */ + function syncHeadingIcon(entry) { + if (entry.marker && entry.iconHeading !== Number.isFinite(entry.courseDeg)) + applySpriteStyle(entry); + } + function addMarker(key, entry) { const marker = state._markers.add({ id: key, @@ -324,6 +335,7 @@ export function createRendering({ state, services, parts }) { for (const entry of state._visible) { // Idle contacts still need a course when their first usable bearing arrives. if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow); + syncHeadingIcon(entry); if (entry.key === state._selectedKey) continue; const course = entry.courseDeg; if (!Number.isFinite(course)) { @@ -427,6 +439,7 @@ export function createRendering({ state, services, parts }) { refreshRotations(now); const selected = state._vehicles.get(state._selectedKey); if (selected && state._visible.has(selected)) { + syncHeadingIcon(selected); selected.marker.rotation = Number.isFinite(selected.courseDeg) ? perspectiveProjectedRotation( state._viewer.scene, From dcd0f2cccd3d312771f7ad1fdf5dd53c0469cd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:42:59 -0300 Subject: [PATCH 05/86] 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 --- DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 31 ++ scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 24 ++ server/providers/local.js | 2 + server/providers/shipwrecks.js | 151 ++++++++ src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/shipwrecks.js | 23 ++ src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 8 + src/data/layerState.js | 1 + src/data/layerState.test.mjs | 50 ++- src/data/layerStateTokenLedger.test.mjs | 71 ++-- src/data/layerStateTokenReservations.json | 1 + src/data/shipwrecksProxy.test.mjs | 132 +++++++ src/i18n/locales/ar.json | 37 +- src/i18n/locales/de.json | 33 +- src/i18n/locales/es.json | 34 +- src/i18n/locales/fr.json | 34 +- src/i18n/locales/it.json | 34 +- src/i18n/locales/ja.json | 32 +- src/i18n/locales/pt.json | 34 +- src/i18n/locales/ru.json | 35 +- src/i18n/locales/uk.json | 35 +- src/i18n/locales/zh-CN.json | 32 +- src/i18n/locales/zh-Hant.json | 32 +- src/i18n/same-as-english.json | 11 + src/layers/shipwrecks/cards.js | 123 +++++++ src/layers/shipwrecks/cards.test.mjs | 63 ++++ src/layers/shipwrecks/eras.js | 33 ++ src/layers/shipwrecks/eras.test.mjs | 23 ++ src/layers/shipwrecks/index.js | 424 +++++++++++++++++++++ src/layers/shipwrecks/records.js | 428 ++++++++++++++++++++++ src/layers/shipwrecks/records.test.mjs | 212 +++++++++++ src/layers/shipwrecks/source.js | 35 ++ src/standalone/layerSources.js | 2 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/voice/layerManifest.js | 2 + 41 files changed, 2168 insertions(+), 73 deletions(-) create mode 100644 server/providers/shipwrecks.js create mode 100644 src/app/layers/shipwrecks.js create mode 100644 src/data/shipwrecksProxy.test.mjs create mode 100644 src/layers/shipwrecks/cards.js create mode 100644 src/layers/shipwrecks/cards.test.mjs create mode 100644 src/layers/shipwrecks/eras.js create mode 100644 src/layers/shipwrecks/eras.test.mjs create mode 100644 src/layers/shipwrecks/index.js create mode 100644 src/layers/shipwrecks/records.js create mode 100644 src/layers/shipwrecks/records.test.mjs create mode 100644 src/layers/shipwrecks/source.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index cac0b5ab7..805771b67 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -39,6 +39,8 @@ How to read this: | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use โ€” one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." โ€” [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground ยฉ 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground ยฉ by S2A1, CC BY-NC-SA 4.0" โ€” shown in-app | +| **EMODnet Human Activities โ€” Ship wrecks** (`ows.emodnet-humanactivities.eu/wfs`, layer `emodnet:heritageshipwrecks`) | Shipwrecks layer: about 7,000 wrecks in European waters (name, sinking year and text, vessel type, least depth and its quality, position accuracy, period, cargo, protected status). Keyless WFS GeoJSON, fetched by `/api/shipwrecks` once a day; rows sourced from wrecksite.eu are skipped and text naming people is dropped | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (EMODnet catalogue record `e965088b-a265-4517-84df-c49b156af8a7`; data originator AND-International, compiled from SHOM, National Monuments Service, Historic England and the Oxford Roman Economy Project); fetched at runtime, not stored in this repository | "Shipwrecks (Europe): EMODnet Human Activities (data originator AND-International, โ€ฆ), CC BY 4.0" โ€” shown in-app | +| **Wikidata โ€” shipwrecks** (`query.wikidata.org/sparql`) | Shipwrecks layer: shipwreck items (Q852190) with a coordinate and at least one Wikipedia article: name, ship type, sinking date, death count, description and article link. Keyless, fetched by `/api/shipwrecks` once a day | [CC0](https://creativecommons.org/publicdomain/zero/1.0/); fetched at runtime | "Shipwrecks (worldwide): Wikidata (CC0)" โ€” shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb โ†—" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only โ€” not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap ยฉ OpenMapTiles | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..e0df74333 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,6 +98,36 @@ across the antimeridian, so they cannot use the strict ยฑ180 check as-is, and `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own implementations. +## Shipwrecks โ€” EMODnet and Wikidata + +The Events group includes **Shipwrecks** (`shipwrecks`, share-link token `9`), +off by default. `/api/shipwrecks` downloads EMODnet Human Activities' +`emodnet:heritageshipwrecks` WFS layer (about 7,000 wrecks in European waters +compiled from SHOM, Ireland's National Monuments Service, Historic England and +the Oxford Roman Economy Project; CC BY 4.0) and a Wikidata SPARQL query for +shipwreck items with a coordinate and at least one Wikipedia article (about +1,450 worldwide; CC0), at most once a day, and serves the last good snapshot as +stale for up to seven days when a refresh fails. One source failing still serves +the other. EMODnet rows the portal took from wrecksite.eu are skipped (that site +is not openly licensed). A Wikidata wreck with the same name within 3 km of an +EMODnet wreck is merged into one record that keeps the article link and takes +EMODnet's year, sinking text, depth and position accuracy. Only ship columns are +read: EMODnet's long histories (`ship_char`, `obj_desc`) are not, and any +sinking or cargo text that names a person (owner, master, a diver who reported +it) is dropped whole; deaths appear only as a Wikidata count. + +The globe draws a small hull marker per wreck, ground-clamped with the depth +test off and the horizon cull GhostMaps uses, coloured by era: before 1900 +(ancient wrecks dated only by period count here), 1900โ€“1945, after 1945, and +date unknown. Four row chips show or hide each era (session-only, not in the +share link). Clicking a marker opens a `stateless` card with the name, vessel +type, sinking date or year (or the archaeological dating), what happened, deaths, +least depth and its quality, how well the position is known, period notes or +the Wikidata description, cargo or finds, protected status, waters and source. +Clicking the card opens the Wikipedia article (else the Wikidata item), the +wreck's own web page when EMODnet lists one, else EMODnet's portal. Voice does +not toggle this layer yet (prompt budget, as for the airspace layer). + ## Prohibited & Restricted Airspace โ€” FAA special-use airspace The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, @@ -4212,6 +4242,7 @@ its criteria cannot be silently ignored. | Ski Pistes โ›ท (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | โ€” (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Fire Perimeters ๐Ÿ”ฅ | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | | Prohibited & Restricted Airspace ๐Ÿšซ | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | +| Shipwrecks โš“ (`shipwrecks`) | EMODnet Human Activities heritage shipwrecks (European waters, keyless WFS, CC BY 4.0) and Wikidata (notable wrecks worldwide, keyless, CC0) | `src/layers/shipwrecks/` via `src/app/layers/shipwrecks.js` | `/api/shipwrecks` | 6 h page check; server refresh once a day (stale-on-error up to 7 days) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..d3eb3d95b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1267,5 +1267,9 @@ "src/voice/geminiTextChat.js", "src/voice/panelLayout.js", "src/voice/textChat.js", - "src/voice/textProtocol.js" + "src/voice/textProtocol.js", + "src/layers/shipwrecks/records.test.mjs", + "src/layers/shipwrecks/cards.test.mjs", + "src/layers/shipwrecks/eras.test.mjs", + "src/data/shipwrecksProxy.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d008ceaaa..7460d06d2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2939,6 +2939,12 @@ "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/source.js", + "src/app/layers/shipwrecks.js", + "src/layers/shipwrecks/index.js", + "src/layers/shipwrecks/source.js", + "src/layers/shipwrecks/records.js", + "src/layers/shipwrecks/cards.js", + "src/layers/shipwrecks/eras.js", "src/app/layers/droneAirspace.js", "src/layers/droneAirspace/index.js", "src/layers/droneAirspace/source.js", @@ -3690,6 +3696,12 @@ "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/source.js", + "src/app/layers/shipwrecks.js", + "src/layers/shipwrecks/index.js", + "src/layers/shipwrecks/source.js", + "src/layers/shipwrecks/records.js", + "src/layers/shipwrecks/cards.js", + "src/layers/shipwrecks/eras.js", "src/app/layers/droneAirspace.js", "src/layers/droneAirspace/index.js", "src/layers/droneAirspace/source.js", @@ -4751,6 +4763,12 @@ "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", "src/layers/ghostMaps/source.js", + "src/app/layers/shipwrecks.js", + "src/layers/shipwrecks/index.js", + "src/layers/shipwrecks/source.js", + "src/layers/shipwrecks/records.js", + "src/layers/shipwrecks/cards.js", + "src/layers/shipwrecks/eras.js", "src/sources/httpBody.js", "src/data/geoFileImport.js", "src/i18n/marker.js", @@ -4778,6 +4796,12 @@ "exports": ["./layers/ghostmaps/source"], "modules": [ "src/layers/ghostMaps/source.js", + "src/app/layers/shipwrecks.js", + "src/layers/shipwrecks/index.js", + "src/layers/shipwrecks/source.js", + "src/layers/shipwrecks/records.js", + "src/layers/shipwrecks/cards.js", + "src/layers/shipwrecks/eras.js", "src/layers/ghostMaps/catalog.js", "src/sources/httpBody.js", "src/i18n/marker.js" diff --git a/server/providers/local.js b/server/providers/local.js index 7067f3ec5..2b3b27c54 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -28,6 +28,7 @@ import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { ghostMapsProxy } from './ghostMaps.js'; +import { shipwrecksProxy } from './shipwrecks.js'; import { droneAirspaceProxy } from './droneAirspace.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -80,6 +81,7 @@ function localProviderPlugins({ firePerimetersProxy(), gnssIntegrityProxy(), ghostMapsProxy(), + shipwrecksProxy(), droneAirspaceProxy(), keySetupEndpoint(), ]; diff --git a/server/providers/shipwrecks.js b/server/providers/shipwrecks.js new file mode 100644 index 000000000..44491209d --- /dev/null +++ b/server/providers/shipwrecks.js @@ -0,0 +1,151 @@ +import { + EMODNET_WFS_URL, + WIKIDATA_QUERY, + mergeShipwreckRecords, + normalizeEmodnetFeatures, + normalizeWikidataResults, +} from '../../src/layers/shipwrecks/records.js'; +import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; +import { clientKey, makeRateLimiter } from './common/rate-limit.js'; + +const MIB = 1024 * 1024; +/** EMODnet republishes the wreck layer about once a year; Wikidata daily at most. */ +const REFRESH_MS = 24 * 60 * 60 * 1000; +/** A failed refresh keeps serving the last snapshot this long, marked stale. */ +const STALE_MAX_MS = 7 * REFRESH_MS; +// Only the columns the card uses; ship_char/obj_desc histories can name people +// and are not read beyond a short vessel type. +const EMODNET_PROPERTIES = [ + 'source_id', + 'country', + 'name', + 'least_depth', + 'depth_prec', + 'loc_prec', + 'sink_context', + 'sink_yr', + 'dating', + 'period', + 'statutory', + 'ship_char', + 'obj_type', + 'artefacts', + 'source_inf', + 'website1', + 'point_info', + 'the_geom', +].join(','); +const EMODNET_URL = `${EMODNET_WFS_URL}&propertyName=${EMODNET_PROPERTIES}`; +const WIKIDATA_URL = + 'https://query.wikidata.org/sparql?format=json&query=' + + encodeURIComponent(WIKIDATA_QUERY); +// Wikimedia asks automated clients to name themselves. +const USER_AGENT = + "God's Eye View shipwrecks layer (https://github.com/bilawalsidhu/gods-eye-view)"; + +/** Fixed-origin, cached EMODnet + Wikidata shipwreck snapshot for dev and preview. */ +export function shipwrecksProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + let snapshot = null; + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 }); + + async function upstreamJson(url, maxBytes) { + const signal = AbortSignal.timeout(90_000); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + return readResponseJsonCapped(response, maxBytes, signal); + } + + async function fetchEmodnet() { + const records = normalizeEmodnetFeatures( + await upstreamJson(EMODNET_URL, 32 * MIB), + ); + if (!records.length) throw new Error('invalid_emodnet_layer'); + return records; + } + + async function fetchWikidata() { + return normalizeWikidataResults(await upstreamJson(WIKIDATA_URL, 16 * MIB)); + } + + /** One source failing still serves the other; both failing is an error. */ + async function load() { + const [emodnet, wikidata] = await Promise.allSettled([ + fetchEmodnet(), + fetchWikidata(), + ]); + if (emodnet.status === 'rejected' && wikidata.status === 'rejected') + throw new Error('shipwrecks_unavailable'); + const records = mergeShipwreckRecords( + emodnet.status === 'fulfilled' ? emodnet.value : [], + wikidata.status === 'fulfilled' ? wikidata.value : [], + ); + return { + fetchedAt: now(), + sources: { + emodnet: emodnet.status === 'fulfilled', + wikidata: wikidata.status === 'fulfilled', + }, + records, + }; + } + + async function acquire() { + if (snapshot && now() - snapshot.fetchedAt < REFRESH_MS) + return { value: snapshot, stale: false }; + try { + const { promise } = coalesceProxyRequest(inFlight, 'snapshot', load); + snapshot = await promise; + return { value: snapshot, stale: false }; + } catch (error) { + if (snapshot && now() - snapshot.fetchedAt <= STALE_MAX_MS) + return { value: snapshot, stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(stale ? { 'X-Data-Stale': 'true' } : {}), + }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire(); + json(200, stale ? { ...value, stale: true } : value, stale); + } catch { + json(502, { error: 'shipwrecks_unavailable' }); + } + } + + return { + name: 'shipwrecks', + configureServer({ middlewares }) { + middlewares.use('/api/shipwrecks', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/shipwrecks', handler); + }, + }; +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 488a5e9ab..74c6df1b7 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -29,6 +29,7 @@ import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js'; +import { createApplicationShipwrecks } from './layers/shipwrecks.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createOsmOverlayLayers } from '../data/osmOverlays.js'; @@ -134,6 +135,7 @@ export function createApplicationCatalog({ windowMs: sources['gnss-integrity'].windowMs, }), createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), + createApplicationShipwrecks({ source: sources.shipwrecks }), createApplicationDroneAirspace({ source: sources['drone-airspace'], }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 82a1dbcf0..3d61df3b3 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 37); + assert.equal(first.layers.length, 38); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'osm-places'), @@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 37); + assert.equal(catalog.layers.length, 38); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/shipwrecks.js b/src/app/layers/shipwrecks.js new file mode 100644 index 000000000..ece2d2dce --- /dev/null +++ b/src/app/layers/shipwrecks.js @@ -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, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index a12378260..c45b63e0f 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'air-quality': contract(['air-quality'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), + shipwrecks: contract(['shipwrecks'], ['getSnapshot']), 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index cee9c1246..2a18039f2 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 37); + assert.equal(actual.layers.length, 38); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 29075550c..6da48aa00 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -492,6 +492,14 @@ export const DATA_CREDITS = [ 'licensed under CC BY-NC-SA 4.0. ' + 'Shown as published; reports are S2 assessments, not verified fact.', }, + { + key: 'shipwrecks', + html: + 'Shipwrecks (Europe): EMODnet Human Activities ' + + '(data originator AND-International, compiled from SHOM, Ireland’s National Monuments Service, Historic England and the Oxford Roman Economy Project), ' + + 'licensed under CC BY 4.0' + + ' ยท Shipwrecks (worldwide): Wikidata (CC0)', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index 46dadf9a0..9f26d6cc9 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -691,6 +691,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'satellites', }), Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }), + Object.freeze({ id: 'shipwrecks', token: '9', disposition: 'enabled-only' }), Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }), Object.freeze({ id: 'street-level', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9d30e8973..b6dfd5354 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -203,8 +203,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 35); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35); + assert.equal(REGISTERED_LAYER_IDS.length, 36); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -243,19 +243,17 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '9'); + assert.equal(nextLayerStateToken(), '00'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '9', - bravo: '00', + alpha: '00', + bravo: '01', }), - '01', + '02', ); - const digitsExhausted = { - ...LAYER_STATE_TOKEN_RESERVATIONS, - ...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])), - }; + // shipwrecks took the last single digit, 9. + const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); assert.equal( nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }), @@ -306,8 +304,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '9', - next: '00', + future: '00', + next: '01', }), true, ); @@ -315,12 +313,12 @@ test('production registry is exact, canonical, and rejects incomplete contracts' () => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '00', + future: '01', }), - /next free token 9/, + /next free token 00/, ); const beforeLastDigit = { ...digitsExhausted }; - delete beforeLastDigit['prior-9']; + delete beforeLastDigit.shipwrecks; assert.equal( validateLayerStateAllocations(beforeLastDigit, { ...beforeLastDigit, @@ -340,10 +338,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00', competing: '00' }, ), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2711,6 +2709,22 @@ test('drone restrictions uses digit 8 without colliding with fire perimeters', ( ); }); +test('shipwrecks uses digit 9 without colliding with drone restrictions', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=9.8')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'drone-airspace', + 'shipwrecks', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'shipwrecks').token, + '9', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('GNSS interference uses digit 3 without colliding with other digit tokens', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index d46782378..44086b4bd 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -140,6 +140,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'sea-marks': '6', 'ski-pistes': '7', 'drone-airspace': '8', + shipwrecks: '9', }, ); assert.throws( @@ -165,9 +166,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '9']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '00']]) .future, - '9', + '00', ); assert.throws( () => @@ -185,14 +186,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '9']] }, + { rows: [...reservationRows, ['retired-layer', '00']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); + assert.equal(nextLayerStateToken(published), '01'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '00', + newcomer: '01', }), true, ); @@ -200,15 +201,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '9', + competing: '00', }), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '9', + newcomer: '00', }), /changed or removed: retired-layer/, ); @@ -216,8 +217,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => { +test('PR B manually replaces provisional 00 with 01 after PR A publishes 00', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '00']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -238,8 +239,8 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '9']], - [{ id: 'pr-b', token: '9' }], + [...reservationRows, ['pr-b', '00']], + [{ id: 'pr-b', token: '00' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -247,10 +248,10 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '9']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '00']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '9' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '00' }, ], ); const staleRebase = runCheck(); @@ -258,16 +259,16 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); - const validBaselineRows = [...reservationRows, ['pr-a', '9']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]); + assert.equal(nextLayerStateToken(published), '01'); + const validBaselineRows = [...reservationRows, ['pr-a', '00']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '00' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 00/); + assert.match(helper.stdout, /pr-b: 01/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -275,26 +276,23 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '00']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '01']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '00' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '01' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /36 published, 1 new/); + assert.match(corrected.stdout, /37 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]); - const allDigits = parseLayerStateTokenReservations([ - ...reservationRows, - ...digitRows, - ]); + // shipwrecks took the last single digit, 9. + const allDigits = parseLayerStateTokenReservations(reservationRows); const beforeNine = { ...allDigits }; - delete beforeNine['retired-9']; + delete beforeNine['shipwrecks']; assert.equal( validateLayerStateAllocations(beforeNine, { ...beforeNine, @@ -340,26 +338,19 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'9'].map((digit) => [ - `qa-prior-digit-${digit}`, - digit, - ]); const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({ id: `qa-layer-${token}`, token, })); const fixtureRows = [ ...reservationRows, - ...priorDigits, ...fixtureLayers.map(({ id, token }) => [id, token]), ]; const fixtureModule = writeCodecFixture(cwd, fixtureRows, fixtureLayers); const codec = await import(pathToFileURL(fixtureModule)); assert.equal(codec.validateLayerStateRegistry(), true); assert.equal( - nextLayerStateToken( - parseLayerStateTokenReservations([...reservationRows, ...priorDigits]), - ), + nextLayerStateToken(parseLayerStateTokenReservations(reservationRows)), '00', ); assert.equal( @@ -377,7 +368,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 81); + assert.equal(params.get('l').length, 83); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -395,7 +386,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 36); + assert.equal(Object.keys(published).length, 37); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 836112713..aab62411b 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -24,6 +24,7 @@ ["s2-ghostmaps", "5"], ["satellites", "s"], ["sea-marks", "6"], + ["shipwrecks", "9"], ["ski-pistes", "7"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], diff --git a/src/data/shipwrecksProxy.test.mjs b/src/data/shipwrecksProxy.test.mjs new file mode 100644 index 000000000..45e6b6973 --- /dev/null +++ b/src/data/shipwrecksProxy.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { shipwrecksProxy } from '../../server/providers/shipwrecks.js'; + +const EMODNET = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'heritageshipwrecks.1', + geometry: { type: 'Point', coordinates: [-0.92, 50.74] }, + properties: { name: 'HMS/m A1', sink_yr: '1911', country: 'UK' }, + }, + ], +}; + +function fakeFetch({ wikidataFails = false, emodnetFails = false } = {}) { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url: String(url), options }); + const href = String(url); + if (href.includes('query.wikidata.org')) { + if (wikidataFails) return new Response('', { status: 503 }); + return Response.json({ results: { bindings: [] } }); + } + if (emodnetFails) return new Response('', { status: 503 }); + if (href.includes('ows.emodnet-humanactivities.eu')) + return Response.json(EMODNET); + return new Response('', { status: 404 }); + }; + return { fetchImpl, calls }; +} + +function handlerOf(plugin) { + let handler; + plugin.configureServer({ + middlewares: { use: (_path, fn) => (handler = fn) }, + }); + return handler; +} + +function request(handler, { method = 'GET', url = '/' } = {}) { + return new Promise((resolve) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + resolve({ + status: this.status, + headers: this.headers, + body: JSON.parse(body), + }); + }, + }; + handler( + { method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, + res, + ); + }); +} + +test('serves one cached snapshot from fixed upstreams', async () => { + const { fetchImpl, calls } = fakeFetch(); + const handler = handlerOf(shipwrecksProxy({ fetchImpl })); + const first = await request(handler); + assert.equal(first.status, 200); + assert.equal(first.body.records.length, 1); + assert.equal(first.body.records[0].name, 'HMS/m A1'); + assert.equal(first.body.records[0].year, 1911); + assert.deepEqual(first.body.sources, { emodnet: true, wikidata: true }); + for (const { url } of calls) + assert.match( + url, + /^https:\/\/(ows\.emodnet-humanactivities\.eu\/wfs\?|query\.wikidata\.org\/sparql\?)/, + ); + const count = calls.length; + await request(handler); + assert.equal(calls.length, count, 'second request is served from cache'); +}); + +test('one failing source still serves the other; both failing is a 502', async () => { + const partial = handlerOf( + shipwrecksProxy({ + fetchImpl: fakeFetch({ wikidataFails: true }).fetchImpl, + }), + ); + const ok = await request(partial); + assert.equal(ok.status, 200); + assert.deepEqual(ok.body.sources, { emodnet: true, wikidata: false }); + + const down = handlerOf( + shipwrecksProxy({ + fetchImpl: fakeFetch({ wikidataFails: true, emodnetFails: true }) + .fetchImpl, + }), + ); + assert.equal((await request(down)).status, 502); +}); + +test('a failed refresh serves the last snapshot as stale', async () => { + let clock = 0; + let fail = false; + const { fetchImpl: good } = fakeFetch(); + const { fetchImpl: bad } = fakeFetch({ + wikidataFails: true, + emodnetFails: true, + }); + const handler = handlerOf( + shipwrecksProxy({ + fetchImpl: (...args) => (fail ? bad : good)(...args), + now: () => clock, + }), + ); + await request(handler); + fail = true; + clock = 25 * 60 * 60 * 1000; + const stale = await request(handler); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.headers['X-Data-Stale'], 'true'); +}); + +test('rejects other methods and routes', async () => { + const handler = handlerOf( + shipwrecksProxy({ fetchImpl: fakeFetch().fetchImpl }), + ); + assert.equal((await request(handler, { method: 'POST' })).status, 405); + assert.equal((await request(handler, { url: '/x' })).status, 404); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index c9fca14ce..147d9e307 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3404,5 +3404,40 @@ "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only โ€” not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "ุงู„ู…ุฌุงู„ ุงู„ุฌูˆูŠ ุงู„ุฎุงุต ุงู„ู…ุญุธูˆุฑ ูˆุงู„ู…ู‚ูŠู‘ุฏ ู„ุฏู‰ FAA ูู‚ุทุŒ ูˆู„ุง ูŠุดู…ู„ UAS Facility Map ุฃูˆ ู‚ูŠูˆุฏ ุทูŠุฑุงู† UAS. ู„ู„ุนู„ู… ูู‚ุท โ€” ูˆู„ุง ูŠุบู†ูŠ ุนู† ุงู„ุฃุฏูˆุงุช ุงู„ุฑุณู…ูŠุฉ ู„ุชุฎุทูŠุท ุฑุญู„ุงุช UAS (B4UFLY / LAANC).", "Prohibited": "ู…ุญุธูˆุฑ", "Restricted": "ู…ู‚ูŠู‘ุฏ", - "Prohibited & Restricted Airspace": "ุงู„ู…ุฌุงู„ ุงู„ุฌูˆูŠ ุงู„ู…ุญุธูˆุฑ ูˆุงู„ู…ู‚ูŠู‘ุฏ" + "Prohibited & Restricted Airspace": "ุงู„ู…ุฌุงู„ ุงู„ุฌูˆูŠ ุงู„ู…ุญุธูˆุฑ ูˆุงู„ู…ู‚ูŠู‘ุฏ", + "Shipwrecks": "ุญุทุงู… ุงู„ุณูู†", + "Before 1900": "ู‚ุจู„ 1900", + "After 1945": "ุจุนุฏ 1945", + "Date unknown": "ุงู„ุชุงุฑูŠุฎ ุบูŠุฑ ู…ุนุฑูˆู", + "Position: centre of the protected area": "ุงู„ู…ูˆู‚ุน: ู…ุฑูƒุฒ ุงู„ู…ู†ุทู‚ุฉ ุงู„ู…ุญู…ูŠุฉ", + "Position approximate (within about {km} km)": "ุงู„ู…ูˆู‚ุน ุชู‚ุฑูŠุจูŠ (ููŠ ุญุฏูˆุฏ {km} ูƒู… ุชู‚ุฑูŠุจู‹ุง)", + "Position surveyed (within about {m} m)": "ุงู„ู…ูˆู‚ุน ู…ูุณูŽุญ (ููŠ ุญุฏูˆุฏ {m} ู… ุชู‚ุฑูŠุจู‹ุง)", + "Vessel: {value}": "ุงู„ุณููŠู†ุฉ: {value}", + "What happened: {value}": "ู…ุง ุญุฏุซ: {value}", + "Depth: {m} m ({quality})": "ุงู„ุนู…ู‚: {m} ู… ({quality})", + "Depth: {m} m": "ุงู„ุนู…ู‚: {m} ู…", + "Notes: {value}": "ู…ู„ุงุญุธุงุช: {value}", + "Cargo or finds: {value}": "ุงู„ุญู…ูˆู„ุฉ ุฃูˆ ุงู„ู…ูƒุชุดูุงุช: {value}", + "Protected wreck": "ุญุทุงู… ู…ุญู…ูŠ", + "Source: {value}": "ุงู„ู…ุตุฏุฑ: {value}", + "Click the card to open EMODnet โ†—": "ุงู†ู‚ุฑ ุนู„ู‰ ุงู„ุจุทุงู‚ุฉ ู„ูุชุญ EMODnet โ†—", + "Click the card to open the source โ†—": "ุงู†ู‚ุฑ ุนู„ู‰ ุงู„ุจุทุงู‚ุฉ ู„ูุชุญ ุงู„ู…ุตุฏุฑ โ†—", + "Wikidata unavailable; showing EMODnet only": "ูˆูŠูƒูŠ ุจูŠุงู†ุงุช ุบูŠุฑ ู…ุชุงุญุฉุ› ูŠูุนุฑุถ EMODnet ูู‚ุท", + "EMODnet unavailable; showing Wikidata only": "EMODnet ุบูŠุฑ ู…ุชุงุญุ› ุชูุนุฑุถ ูˆูŠูƒูŠ ุจูŠุงู†ุงุช ูู‚ุท", + "Shipwrecks unavailable": "ุจูŠุงู†ุงุช ุญุทุงู… ุงู„ุณูู† ุบูŠุฑ ู…ุชุงุญุฉ", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (ุงู„ู…ูŠุงู‡ ุงู„ุฃูˆุฑูˆุจูŠุฉ) ยท ูˆูŠูƒูŠ ุจูŠุงู†ุงุช (ุงู„ุนุงู„ู…)", + "{count} wrecks": { + "zero": "ู„ุง ุญุทุงู… ({count})", + "one": "ุญุทุงู… ูˆุงุญุฏ ({count})", + "two": "ุญุทุงู…ุงู† ({count})", + "few": "{count} ุญุทุงู…ุงุช", + "many": "{count} ุญุทุงู…ู‹ุง", + "other": "{count} ุญุทุงู…" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "ุบุฑู‚ ุณููŠู†ุฉ", + "Fatalities: {count}": "ุงู„ูˆููŠุงุช: {count}", + "Survivors: {count}": "ุงู„ู†ุงุฌูˆู†: {count}", + "Click the card to open the article โ†—": "ุงู†ู‚ุฑ ุนู„ู‰ ุงู„ุจุทุงู‚ุฉ ู„ูุชุญ ุงู„ู…ู‚ุงู„ุฉ โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ูŠุฃุชูŠ ุญุทุงู… ุงู„ุณูู† ุงู„ุชุฑุงุซูŠ ุงู„ุฃูˆุฑูˆุจูŠ ู…ู† EMODnet Human ActivitiesุŒ ูˆูŠุฃุชูŠ ุงู„ุญุทุงู… ุงู„ุจุงุฑุฒ ููŠ ุงู„ุนุงู„ู… ู…ู† ูˆูŠูƒูŠ ุจูŠุงู†ุงุช. ูŠุจูŠู‘ู† ุงู„ู„ูˆู† ู…ุชู‰ ุบุฑู‚ุช ุงู„ุณููŠู†ุฉุŒ ูˆูŠูƒุจุฑ ุญุฌู… ุงู„ุนู„ุงู…ุฉ ู…ุน ุนุฏุฏ ุงู„ูˆููŠุงุช. ุงู†ู‚ุฑ ุนู„ู‰ ุนู„ุงู…ุฉ ู„ุนุฑุถ ุจุทุงู‚ุชู‡ุง." } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bb683ed9f..cbcf280e2 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3104,5 +3104,36 @@ "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlรผssel abgelehnt. Den Schlรผssel in den Anbieter-Einstellungen prรผfen.", "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff fรผr diesen Schlรผssel verweigert. Projekt, Region oder Berechtigungen des Schlรผssels prรผfen.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prรผfen oder in den Einstellungen einen anderen KI-Anbieter wรคhlen." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prรผfen oder in den Einstellungen einen anderen KI-Anbieter wรคhlen.", + "Shipwrecks": "Schiffswracks", + "Before 1900": "Vor 1900", + "After 1945": "Nach 1945", + "Date unknown": "Datum unbekannt", + "Position: centre of the protected area": "Position: Mitte des Schutzgebiets", + "Position approximate (within about {km} km)": "Position ungefรคhr (auf etwa {km} km genau)", + "Position surveyed (within about {m} m)": "Position vermessen (auf etwa {m} m genau)", + "Vessel: {value}": "Schiff: {value}", + "What happened: {value}": "Was geschah: {value}", + "Depth: {m} m ({quality})": "Tiefe: {m} m ({quality})", + "Depth: {m} m": "Tiefe: {m} m", + "Notes: {value}": "Hinweise: {value}", + "Cargo or finds: {value}": "Ladung oder Funde: {value}", + "Protected wreck": "Geschรผtztes Wrack", + "Source: {value}": "Quelle: {value}", + "Click the card to open EMODnet โ†—": "Karte anklicken, um EMODnet zu รถffnen โ†—", + "Click the card to open the source โ†—": "Karte anklicken, um die Quelle zu รถffnen โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfรผgbar; nur EMODnet wird angezeigt", + "EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfรผgbar; nur Wikidata wird angezeigt", + "Shipwrecks unavailable": "Schiffswracks nicht verfรผgbar", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (europรคische Gewรคsser) ยท Wikidata (weltweit)", + "{count} wrecks": { + "one": "{count} Wrack", + "other": "{count} Wracks" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "Schiffbruch", + "Fatalities: {count}": "Todesopfer: {count}", + "Survivors: {count}": "รœberlebende: {count}", + "Click the card to open the article โ†—": "Karte anklicken, um den Artikel zu รถffnen โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europรคische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wรคchst mit der Zahl der Todesopfer. Klicke auf eine Markierung fรผr ihre Karte." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 10dcbef8f..6df928b0a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3179,5 +3179,37 @@ "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazรณ la clave de API. Revรญsala en Ajustes de proveedores.", "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegรณ el acceso a esta clave. Revisa el proyecto, la regiรณn o los permisos de la clave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crรฉdito. Revisa la facturaciรณn o elige otro proveedor de IA en Ajustes." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crรฉdito. Revisa la facturaciรณn o elige otro proveedor de IA en Ajustes.", + "Shipwrecks": "Naufragios", + "Before 1900": "Antes de 1900", + "After 1945": "Despuรฉs de 1945", + "Date unknown": "Fecha desconocida", + "Position: centre of the protected area": "Posiciรณn: centro de la zona protegida", + "Position approximate (within about {km} km)": "Posiciรณn aproximada (margen de unos {km} km)", + "Position surveyed (within about {m} m)": "Posiciรณn levantada (margen de unos {m} m)", + "Vessel: {value}": "Embarcaciรณn: {value}", + "What happened: {value}": "Quรฉ ocurriรณ: {value}", + "Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})", + "Depth: {m} m": "Profundidad: {m} m", + "Notes: {value}": "Notas: {value}", + "Cargo or finds: {value}": "Carga o hallazgos: {value}", + "Protected wreck": "Pecio protegido", + "Source: {value}": "Fuente: {value}", + "Click the card to open EMODnet โ†—": "Haz clic en la ficha para abrir EMODnet โ†—", + "Click the card to open the source โ†—": "Haz clic en la ficha para abrir la fuente โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata", + "Shipwrecks unavailable": "Naufragios no disponibles", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (aguas europeas) ยท Wikidata (todo el mundo)", + "{count} wrecks": { + "one": "{count} naufragio", + "many": "{count} de naufragios", + "other": "{count} naufragios" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "Naufragio", + "Fatalities: {count}": "Fallecidos: {count}", + "Survivors: {count}": "Supervivientes: {count}", + "Click the card to open the article โ†—": "Haz clic en la ficha para abrir el artรญculo โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios histรณricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuรกndo se hundiรณ el barco; el marcador crece con el nรบmero de muertos. Haz clic en un marcador para ver su ficha." } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 108e56f36..b9b53c48d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3179,5 +3179,37 @@ "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusรฉ la clรฉ API. Vรฉrifiez-la dans les paramรจtres des fournisseurs.", "Too many chat requests. Try again in {seconds} s.": "Trop de requรชtes de discussion. Rรฉessayez dans {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusรฉ l'accรจs pour cette clรฉ. Vรฉrifiez le projet, la rรฉgion ou les autorisations de la clรฉ.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crรฉdit. Vรฉrifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramรจtres." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crรฉdit. Vรฉrifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramรจtres.", + "Shipwrecks": "ร‰paves", + "Before 1900": "Avant 1900", + "After 1945": "Aprรจs 1945", + "Date unknown": "Date inconnue", + "Position: centre of the protected area": "Position : centre de la zone protรฉgรฉe", + "Position approximate (within about {km} km)": "Position approximative (ร  environ {km} km prรจs)", + "Position surveyed (within about {m} m)": "Position relevรฉe (ร  environ {m} m prรจs)", + "Vessel: {value}": "Navire : {value}", + "What happened: {value}": "Ce qui s'est passรฉ : {value}", + "Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})", + "Depth: {m} m": "Profondeur : {m} m", + "Notes: {value}": "Notes : {value}", + "Cargo or finds: {value}": "Cargaison ou dรฉcouvertes : {value}", + "Protected wreck": "ร‰pave protรฉgรฉe", + "Source: {value}": "Source : {value}", + "Click the card to open EMODnet โ†—": "Cliquez sur la fiche pour ouvrir EMODnet โ†—", + "Click the card to open the source โ†—": "Cliquez sur la fiche pour ouvrir la source โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affichรฉ", + "EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affichรฉ", + "Shipwrecks unavailable": "ร‰paves indisponibles", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (eaux europรฉennes) ยท Wikidata (monde)", + "{count} wrecks": { + "one": "{count} รฉpave", + "many": "{count} d'รฉpaves", + "other": "{count} รฉpaves" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "Naufrage", + "Fatalities: {count}": "Morts : {count}", + "Survivors: {count}": "Survivants : {count}", + "Click the card to open the article โ†—": "Cliquez sur la fiche pour ouvrir l'article โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les รฉpaves patrimoniales europรฉennes viennent d'EMODnet Human Activities ; les รฉpaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulรฉ ; le repรจre grossit avec le nombre de morts. Cliquez sur un repรจre pour voir sa fiche." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6768f93c7..9310da82c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3179,5 +3179,37 @@ "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.", "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.", + "Shipwrecks": "Relitti", + "Before 1900": "Prima del 1900", + "After 1945": "Dopo il 1945", + "Date unknown": "Data sconosciuta", + "Position: centre of the protected area": "Posizione: centro dell'area protetta", + "Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)", + "Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)", + "Vessel: {value}": "Imbarcazione: {value}", + "What happened: {value}": "Cosa รจ successo: {value}", + "Depth: {m} m ({quality})": "Profonditร : {m} m ({quality})", + "Depth: {m} m": "Profonditร : {m} m", + "Notes: {value}": "Note: {value}", + "Cargo or finds: {value}": "Carico o reperti: {value}", + "Protected wreck": "Relitto protetto", + "Source: {value}": "Fonte: {value}", + "Click the card to open EMODnet โ†—": "Fai clic sulla scheda per aprire EMODnet โ†—", + "Click the card to open the source โ†—": "Fai clic sulla scheda per aprire la fonte โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata non disponibile; solo EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet non disponibile; solo Wikidata", + "Shipwrecks unavailable": "Relitti non disponibili", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (acque europee) ยท Wikidata (mondo)", + "{count} wrecks": { + "one": "{count} relitto", + "many": "{count} di relitti", + "other": "{count} relitti" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "Naufragio", + "Fatalities: {count}": "Vittime: {count}", + "Survivors: {count}": "Sopravvissuti: {count}", + "Click the card to open the article โ†—": "Fai clic sulla scheda per aprire l'articolo โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave รจ affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b4566b4cb..4a1edfb44 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3029,5 +3029,35 @@ "The provider rejected the API key. Check it in Provider Settings.": "ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใŒAPIใ‚ญใƒผใ‚’ๆ‹’ๅฆใ—ใพใ—ใŸใ€‚ใƒ—ใƒญใƒใ‚คใƒ€ใƒผ่จญๅฎšใง็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", "Too many chat requests. Try again in {seconds} s.": "ใƒใƒฃใƒƒใƒˆใฎใƒชใ‚ฏใ‚จใ‚นใƒˆใŒๅคšใ™ใŽใพใ™ใ€‚{seconds}็ง’ๅพŒใซใ‚‚ใ†ไธ€ๅบฆใŠ่ฉฆใ—ใใ ใ•ใ„ใ€‚", "The provider refused access for this key. Check the key's project, region or permissions.": "ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใŒใ“ใฎใ‚ญใƒผใงใฎใ‚ขใ‚ฏใ‚ปใ‚นใ‚’ๆ‹’ๅฆใ—ใพใ—ใŸใ€‚ใ‚ญใƒผใฎใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆใ€ใƒชใƒผใ‚ธใƒงใƒณใ€ๆจฉ้™ใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎใ‚ฏใƒฌใ‚ธใƒƒใƒˆใŒไธ่ถณใ—ใฆใ„ใพใ™ใ€‚่ชฒ้‡‘็Šถๆณใ‚’็ขบ่ชใ™ใ‚‹ใ‹ใ€่จญๅฎšใงๅˆฅใฎAIใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚’้ธใ‚“ใงใใ ใ•ใ„ใ€‚" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎใ‚ฏใƒฌใ‚ธใƒƒใƒˆใŒไธ่ถณใ—ใฆใ„ใพใ™ใ€‚่ชฒ้‡‘็Šถๆณใ‚’็ขบ่ชใ™ใ‚‹ใ‹ใ€่จญๅฎšใงๅˆฅใฎAIใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚’้ธใ‚“ใงใใ ใ•ใ„ใ€‚", + "Shipwrecks": "้›ฃ็ ด่ˆน", + "Before 1900": "1900ๅนดไปฅๅ‰", + "After 1945": "1945ๅนดไปฅ้™", + "Date unknown": "ๆ—ฅไป˜ไธๆ˜Ž", + "Position: centre of the protected area": "ไฝ็ฝฎ: ไฟ่ญทๅŒบๅŸŸใฎไธญๅฟƒ", + "Position approximate (within about {km} km)": "ใŠใŠใ‚ˆใใฎไฝ็ฝฎ๏ผˆ่ชคๅทฎ็ด„{km} km๏ผ‰", + "Position surveyed (within about {m} m)": "ๆธฌ้‡ใ•ใ‚ŒใŸไฝ็ฝฎ๏ผˆ่ชคๅทฎ็ด„{m} m๏ผ‰", + "Vessel: {value}": "่ˆน็จฎ: {value}", + "What happened: {value}": "็ตŒ็ทฏ: {value}", + "Depth: {m} m ({quality})": "ๆฐดๆทฑ: {m} m๏ผˆ{quality}๏ผ‰", + "Depth: {m} m": "ๆฐดๆทฑ: {m} m", + "Notes: {value}": "ๅ‚™่€ƒ: {value}", + "Cargo or finds: {value}": "็ฉ่ทใƒป้บ็‰ฉ: {value}", + "Protected wreck": "ไฟ่ญทๅฏพ่ฑกใฎ้›ฃ็ ด่ˆน", + "Source: {value}": "ๅ‡บๅ…ธ: {value}", + "Click the card to open EMODnet โ†—": "ใ‚ซใƒผใƒ‰ใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆEMODnetใ‚’้–‹ใ โ†—", + "Click the card to open the source โ†—": "ใ‚ซใƒผใƒ‰ใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆๅ‡บๅ…ธใ‚’้–‹ใ โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidataใ‚’ๅˆฉ็”จใงใใพใ›ใ‚“ใ€‚EMODnetใฎใฟ่กจ็คบใ—ใฆใ„ใพใ™", + "EMODnet unavailable; showing Wikidata only": "EMODnetใ‚’ๅˆฉ็”จใงใใพใ›ใ‚“ใ€‚Wikidataใฎใฟ่กจ็คบใ—ใฆใ„ใพใ™", + "Shipwrecks unavailable": "้›ฃ็ ด่ˆนใƒ‡ใƒผใ‚ฟใ‚’ๅˆฉ็”จใงใใพใ›ใ‚“", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet๏ผˆๆฌงๅทžๆตทๅŸŸ๏ผ‰ยท Wikidata๏ผˆไธ–็•Œ๏ผ‰", + "{count} wrecks": { + "other": "้›ฃ็ ด่ˆน {count}ไปถ" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "้›ฃ็ ด", + "Fatalities: {count}": "ๆญป่€…: {count}", + "Survivors: {count}": "็”Ÿๅญ˜่€…: {count}", + "Click the card to open the article โ†—": "ใ‚ซใƒผใƒ‰ใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆ่จ˜ไบ‹ใ‚’้–‹ใ โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ๆฌงๅทžใฎๆญดๅฒ็š„ใช้›ฃ็ ด่ˆนใฏEMODnet Human Activitiesใ€ไธ–็•Œใฎ่‘—ๅใช้›ฃ็ ด่ˆนใฏWikidataใซใ‚ˆใ‚Šใพใ™ใ€‚่‰ฒใฏๆฒˆๆฒกใ—ใŸๆ™‚ๆœŸใ‚’็คบใ—ใ€ใƒžใƒผใ‚ซใƒผใฏๆญป่€…ๆ•ฐใซๅฟœใ˜ใฆๅคงใใใชใ‚Šใพใ™ใ€‚ใƒžใƒผใ‚ซใƒผใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ™ใ‚‹ใจใ‚ซใƒผใƒ‰ใŒ้–‹ใใพใ™ใ€‚" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3f37cde2e..1c9b46418 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3179,5 +3179,37 @@ "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configuraรงรตes de provedores\".", "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisiรงรตes atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a regiรฃo ou as permissรตes da chave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor estรก sem crรฉdito. Verifique o faturamento ou escolha outro provedor de IA em \"Configuraรงรตes\"." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor estรก sem crรฉdito. Verifique o faturamento ou escolha outro provedor de IA em \"Configuraรงรตes\".", + "Shipwrecks": "Naufrรกgios", + "Before 1900": "Antes de 1900", + "After 1945": "Depois de 1945", + "Date unknown": "Data desconhecida", + "Position: centre of the protected area": "Posiรงรฃo: centro da รกrea protegida", + "Position approximate (within about {km} km)": "Posiรงรฃo aproximada (margem de cerca de {km} km)", + "Position surveyed (within about {m} m)": "Posiรงรฃo levantada (margem de cerca de {m} m)", + "Vessel: {value}": "Embarcaรงรฃo: {value}", + "What happened: {value}": "O que aconteceu: {value}", + "Depth: {m} m ({quality})": "Profundidade: {m} m ({quality})", + "Depth: {m} m": "Profundidade: {m} m", + "Notes: {value}": "Notas: {value}", + "Cargo or finds: {value}": "Carga ou achados: {value}", + "Protected wreck": "Naufrรกgio protegido", + "Source: {value}": "Fonte: {value}", + "Click the card to open EMODnet โ†—": "Clique na ficha para abrir o EMODnet โ†—", + "Click the card to open the source โ†—": "Clique na ficha para abrir a fonte โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata indisponรญvel; mostrando sรณ o EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet indisponรญvel; mostrando sรณ o Wikidata", + "Shipwrecks unavailable": "Naufrรกgios indisponรญveis", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (รกguas europeias) ยท Wikidata (mundo)", + "{count} wrecks": { + "one": "{count} naufrรกgio", + "many": "{count} de naufrรกgios", + "other": "{count} naufrรกgios" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "Naufrรกgio", + "Fatalities: {count}": "Mortos: {count}", + "Survivors: {count}": "Sobreviventes: {count}", + "Click the card to open the article โ†—": "Clique na ficha para abrir o artigo โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrรกgios histรณricos europeus vรชm do EMODnet Human Activities; os naufrรกgios notรกveis do mundo vรชm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o nรบmero de mortos. Clique num marcador para ver a ficha." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e279508ef..1d4dbce42 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3254,5 +3254,38 @@ "The provider rejected the API key. Check it in Provider Settings.": "ะŸั€ะพะฒะฐะนะดะตั€ ะพั‚ะบะปะพะฝะธะป ะบะปัŽั‡ API. ะŸั€ะพะฒะตั€ัŒั‚ะต ะตะณะพ ะฒ ยซะะฐัั‚ั€ะพะนะบะฐั… ะฟั€ะพะฒะฐะนะดะตั€ะพะฒยป.", "Too many chat requests. Try again in {seconds} s.": "ะกะปะธัˆะบะพะผ ะผะฝะพะณะพ ะทะฐะฟั€ะพัะพะฒ ะฒ ั‡ะฐั‚ะต. ะŸะพะฒั‚ะพั€ะธั‚ะต ะฟะพะฟั‹ั‚ะบัƒ ั‡ะตั€ะตะท {seconds} ั.", "The provider refused access for this key. Check the key's project, region or permissions.": "ะŸั€ะพะฒะฐะนะดะตั€ ะพั‚ะบะฐะทะฐะป ัั‚ะพะผัƒ ะบะปัŽั‡ัƒ ะฒ ะดะพัั‚ัƒะฟะต. ะŸั€ะพะฒะตั€ัŒั‚ะต ะฟั€ะพะตะบั‚, ั€ะตะณะธะพะฝ ะธะปะธ ั€ะฐะทั€ะตัˆะตะฝะธั ะบะปัŽั‡ะฐ.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ะะฐ ะฒะฐัˆะตะผ ัั‡ั‘ั‚ะต ัƒ ะฟั€ะพะฒะฐะนะดะตั€ะฐ ะทะฐะบะพะฝั‡ะธะปะธััŒ ัั€ะตะดัั‚ะฒะฐ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะพะฟะปะฐั‚ัƒ ะธะปะธ ะฒั‹ะฑะตั€ะธั‚ะต ะดั€ัƒะณะพะณะพ ะฟั€ะพะฒะฐะนะดะตั€ะฐ ะ˜ะ˜ ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั…." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ะะฐ ะฒะฐัˆะตะผ ัั‡ั‘ั‚ะต ัƒ ะฟั€ะพะฒะฐะนะดะตั€ะฐ ะทะฐะบะพะฝั‡ะธะปะธััŒ ัั€ะตะดัั‚ะฒะฐ. ะŸั€ะพะฒะตั€ัŒั‚ะต ะพะฟะปะฐั‚ัƒ ะธะปะธ ะฒั‹ะฑะตั€ะธั‚ะต ะดั€ัƒะณะพะณะพ ะฟั€ะพะฒะฐะนะดะตั€ะฐ ะ˜ะ˜ ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั….", + "Shipwrecks": "ะšะพั€ะฐะฑะปะตะบั€ัƒัˆะตะฝะธั", + "Before 1900": "ะ”ะพ 1900", + "After 1945": "ะŸะพัะปะต 1945", + "Date unknown": "ะ”ะฐั‚ะฐ ะฝะตะธะทะฒะตัั‚ะฝะฐ", + "Position: centre of the protected area": "ะŸะพะปะพะถะตะฝะธะต: ั†ะตะฝั‚ั€ ะพั…ั€ะฐะฝัะตะผะพะน ะทะพะฝั‹", + "Position approximate (within about {km} km)": "ะŸะพะปะพะถะตะฝะธะต ะฟั€ะธะฑะปะธะทะธั‚ะตะปัŒะฝะพะต (ั‚ะพั‡ะฝะพัั‚ัŒ ะพะบะพะปะพ {km} ะบะผ)", + "Position surveyed (within about {m} m)": "ะŸะพะปะพะถะตะฝะธะต ะธะทะผะตั€ะตะฝะพ (ั‚ะพั‡ะฝะพัั‚ัŒ ะพะบะพะปะพ {m} ะผ)", + "Vessel: {value}": "ะกัƒะดะฝะพ: {value}", + "What happened: {value}": "ะงั‚ะพ ะฟั€ะพะธะทะพัˆะปะพ: {value}", + "Depth: {m} m ({quality})": "ะ“ะปัƒะฑะธะฝะฐ: {m} ะผ ({quality})", + "Depth: {m} m": "ะ“ะปัƒะฑะธะฝะฐ: {m} ะผ", + "Notes: {value}": "ะŸั€ะธะผะตั‡ะฐะฝะธั: {value}", + "Cargo or finds: {value}": "ะ“ั€ัƒะท ะธะปะธ ะฝะฐั…ะพะดะบะธ: {value}", + "Protected wreck": "ะžั…ั€ะฐะฝัะตะผั‹ะน ะพะฑัŠะตะบั‚", + "Source: {value}": "ะ˜ัั‚ะพั‡ะฝะธะบ: {value}", + "Click the card to open EMODnet โ†—": "ะะฐะถะผะธั‚ะต ะฝะฐ ะบะฐั€ั‚ะพั‡ะบัƒ, ั‡ั‚ะพะฑั‹ ะพั‚ะบั€ั‹ั‚ัŒ EMODnet โ†—", + "Click the card to open the source โ†—": "ะะฐะถะผะธั‚ะต ะฝะฐ ะบะฐั€ั‚ะพั‡ะบัƒ, ั‡ั‚ะพะฑั‹ ะพั‚ะบั€ั‹ั‚ัŒ ะธัั‚ะพั‡ะฝะธะบ โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata ะฝะตะดะพัั‚ัƒะฟะฝะฐ; ะฟะพะบะฐะทะฐะฝ ั‚ะพะปัŒะบะพ EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet ะฝะตะดะพัั‚ัƒะฟะตะฝ; ะฟะพะบะฐะทะฐะฝะฐ ั‚ะพะปัŒะบะพ Wikidata", + "Shipwrecks unavailable": "ะ”ะฐะฝะฝั‹ะต ะพ ะบะพั€ะฐะฑะปะตะบั€ัƒัˆะตะฝะธัั… ะฝะตะดะพัั‚ัƒะฟะฝั‹", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (ะฒะพะดั‹ ะ•ะฒั€ะพะฟั‹) ยท Wikidata (ะฒะตััŒ ะผะธั€)", + "{count} wrecks": { + "one": "{count} ะทะฐั‚ะพะฝัƒะฒัˆะตะต ััƒะดะฝะพ", + "few": "{count} ะทะฐั‚ะพะฝัƒะฒัˆะธั… ััƒะดะฝะฐ", + "many": "{count} ะทะฐั‚ะพะฝัƒะฒัˆะธั… ััƒะดะพะฒ", + "other": "{count} ะทะฐั‚ะพะฝัƒะฒัˆะตะณะพ ััƒะดะฝะฐ" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "ะšะพั€ะฐะฑะปะตะบั€ัƒัˆะตะฝะธะต", + "Fatalities: {count}": "ะŸะพะณะธะฑัˆะธั…: {count}", + "Survivors: {count}": "ะ’ั‹ะถะธะฒัˆะธั…: {count}", + "Click the card to open the article โ†—": "ะะฐะถะผะธั‚ะต ะฝะฐ ะบะฐั€ั‚ะพั‡ะบัƒ, ั‡ั‚ะพะฑั‹ ะพั‚ะบั€ั‹ั‚ัŒ ัั‚ะฐั‚ัŒัŽ โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ะ˜ัั‚ะพั€ะธั‡ะตัะบะธะต ะทะฐั‚ะพะฝัƒะฒัˆะธะต ััƒะดะฐ ะ•ะฒั€ะพะฟั‹ โ€” ะธะท EMODnet Human Activities, ะธะทะฒะตัั‚ะฝั‹ะต ะบะพั€ะฐะฑะปะตะบั€ัƒัˆะตะฝะธั ะฟะพ ะฒัะตะผัƒ ะผะธั€ัƒ โ€” ะธะท Wikidata. ะฆะฒะตั‚ ะฟะพะบะฐะทั‹ะฒะฐะตั‚, ะบะพะณะดะฐ ััƒะดะฝะพ ะทะฐั‚ะพะฝัƒะปะพ; ะผะฐั€ะบะตั€ ั€ะฐัั‚ั‘ั‚ ั ั‡ะธัะปะพะผ ะฟะพะณะธะฑัˆะธั…. ะะฐะถะผะธั‚ะต ะฝะฐ ะผะฐั€ะบะตั€, ั‡ั‚ะพะฑั‹ ะพั‚ะบั€ั‹ั‚ัŒ ะบะฐั€ั‚ะพั‡ะบัƒ." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8c3b634b9..d8b9296f0 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3254,5 +3254,38 @@ "The provider rejected the API key. Check it in Provider Settings.": "ะŸะพัั‚ะฐั‡ะฐะปัŒะฝะธะบ ะฒั–ะดั…ะธะปะธะฒ ะบะปัŽั‡ API. ะŸะตั€ะตะฒั–ั€ั‚ะต ะนะพะณะพ ะฒ ยซะะฐะปะฐัˆั‚ัƒะฒะฐะฝะฝัั… ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบั–ะฒยป.", "Too many chat requests. Try again in {seconds} s.": "ะ—ะฐะฑะฐะณะฐั‚ะพ ะทะฐะฟะธั‚ั–ะฒ ะดะพ ั‡ะฐั‚ัƒ. ะกะฟั€ะพะฑัƒะนั‚ะต ั‰ะต ั€ะฐะท ั‡ะตั€ะตะท {seconds} ั.", "The provider refused access for this key. Check the key's project, region or permissions.": "ะŸะพัั‚ะฐั‡ะฐะปัŒะฝะธะบ ะฒั–ะดะผะพะฒะธะฒ ั†ัŒะพะผัƒ ะบะปัŽั‡ัƒ ะฒ ะดะพัั‚ัƒะฟั–. ะŸะตั€ะตะฒั–ั€ั‚ะต ะฟั€ะพั”ะบั‚, ั€ะตะณั–ะพะฝ ะฐะฑะพ ะดะพะทะฒะพะปะธ ะบะปัŽั‡ะฐ.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ะะฐ ะฒะฐัˆะพะผัƒ ั€ะฐั…ัƒะฝะบัƒ ะฒ ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบะฐ ะทะฐะบั–ะฝั‡ะธะปะธัั ะบะพัˆั‚ะธ. ะŸะตั€ะตะฒั–ั€ั‚ะต ะพะฟะปะฐั‚ัƒ ะฐะฑะพ ะฒะธะฑะตั€ั–ั‚ัŒ ั–ะฝัˆะพะณะพ ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบะฐ ะจะ† ะฒ ะฝะฐะปะฐัˆั‚ัƒะฒะฐะฝะฝัั…." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ะะฐ ะฒะฐัˆะพะผัƒ ั€ะฐั…ัƒะฝะบัƒ ะฒ ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบะฐ ะทะฐะบั–ะฝั‡ะธะปะธัั ะบะพัˆั‚ะธ. ะŸะตั€ะตะฒั–ั€ั‚ะต ะพะฟะปะฐั‚ัƒ ะฐะฑะพ ะฒะธะฑะตั€ั–ั‚ัŒ ั–ะฝัˆะพะณะพ ะฟะพัั‚ะฐั‡ะฐะปัŒะฝะธะบะฐ ะจะ† ะฒ ะฝะฐะปะฐัˆั‚ัƒะฒะฐะฝะฝัั….", + "Shipwrecks": "ะšะพั€ะฐะฑะตะปัŒะฝั– ะฐะฒะฐั€ั–ั—", + "Before 1900": "ะ”ะพ 1900", + "After 1945": "ะŸั–ัะปั 1945", + "Date unknown": "ะ”ะฐั‚ะฐ ะฝะตะฒั–ะดะพะผะฐ", + "Position: centre of the protected area": "ะŸะพะปะพะถะตะฝะฝั: ั†ะตะฝั‚ั€ ะพั…ะพั€ะพะฝะฝะพั— ะทะพะฝะธ", + "Position approximate (within about {km} km)": "ะŸะพะปะพะถะตะฝะฝั ะฟั€ะธะฑะปะธะทะฝะต (ั‚ะพั‡ะฝั–ัั‚ัŒ ะฑะปะธะทัŒะบะพ {km} ะบะผ)", + "Position surveyed (within about {m} m)": "ะŸะพะปะพะถะตะฝะฝั ะฒะธะผั–ั€ัะฝะพ (ั‚ะพั‡ะฝั–ัั‚ัŒ ะฑะปะธะทัŒะบะพ {m} ะผ)", + "Vessel: {value}": "ะกัƒะดะฝะพ: {value}", + "What happened: {value}": "ะฉะพ ัั‚ะฐะปะพัั: {value}", + "Depth: {m} m ({quality})": "ะ“ะปะธะฑะธะฝะฐ: {m} ะผ ({quality})", + "Depth: {m} m": "ะ“ะปะธะฑะธะฝะฐ: {m} ะผ", + "Notes: {value}": "ะŸั€ะธะผั–ั‚ะบะธ: {value}", + "Cargo or finds: {value}": "ะ’ะฐะฝั‚ะฐะถ ะฐะฑะพ ะทะฝะฐั…ั–ะดะบะธ: {value}", + "Protected wreck": "ะžั…ะพั€ะพะฝัŽะฒะฐะฝะธะน ะพะฑ'ั”ะบั‚", + "Source: {value}": "ะ”ะถะตั€ะตะปะพ: {value}", + "Click the card to open EMODnet โ†—": "ะะฐั‚ะธัะฝั–ั‚ัŒ ะบะฐั€ั‚ะบัƒ, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ EMODnet โ†—", + "Click the card to open the source โ†—": "ะะฐั‚ะธัะฝั–ั‚ัŒ ะบะฐั€ั‚ะบัƒ, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ ะดะถะตั€ะตะปะพ โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata ะฝะตะดะพัั‚ัƒะฟะฝะฐ; ะฟะพะบะฐะทะฐะฝะพ ะปะธัˆะต EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet ะฝะตะดะพัั‚ัƒะฟะฝะธะน; ะฟะพะบะฐะทะฐะฝะพ ะปะธัˆะต Wikidata", + "Shipwrecks unavailable": "ะ”ะฐะฝั– ะฟั€ะพ ะบะพั€ะฐะฑะตะปัŒะฝั– ะฐะฒะฐั€ั–ั— ะฝะตะดะพัั‚ัƒะฟะฝั–", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet (ะฒะพะดะธ ะ„ะฒั€ะพะฟะธ) ยท Wikidata (ัƒะฒะตััŒ ัะฒั–ั‚)", + "{count} wrecks": { + "one": "{count} ะทะฐั‚ะพะฝัƒะปะต ััƒะดะฝะพ", + "few": "{count} ะทะฐั‚ะพะฝัƒะปั– ััƒะดะฝะฐ", + "many": "{count} ะทะฐั‚ะพะฝัƒะปะธั… ััƒะดะตะฝ", + "other": "{count} ะทะฐั‚ะพะฝัƒะปะพะณะพ ััƒะดะฝะฐ" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "ะšะพั€ะฐะฑะตะปัŒะฝะฐ ะฐะฒะฐั€ั–ั", + "Fatalities: {count}": "ะ—ะฐะณะธะฑะปะธั…: {count}", + "Survivors: {count}": "ะขะธั…, ั…ั‚ะพ ะฒะธะถะธะฒ: {count}", + "Click the card to open the article โ†—": "ะะฐั‚ะธัะฝั–ั‚ัŒ ะบะฐั€ั‚ะบัƒ, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ ัั‚ะฐั‚ั‚ัŽ โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ะ†ัั‚ะพั€ะธั‡ะฝั– ะทะฐั‚ะพะฝัƒะปั– ััƒะดะฝะฐ ะ„ะฒั€ะพะฟะธ โ€” ะท EMODnet Human Activities, ะฒั–ะดะพะผั– ะบะพั€ะฐะฑะตะปัŒะฝั– ะฐะฒะฐั€ั–ั— ัะฒั–ั‚ัƒ โ€” ะท Wikidata. ะšะพะปั–ั€ ะฟะพะบะฐะทัƒั”, ะบะพะปะธ ััƒะดะฝะพ ะทะฐั‚ะพะฝัƒะปะพ; ะผะฐั€ะบะตั€ ะทะฑั–ะปัŒัˆัƒั”ั‚ัŒัั ะท ะบั–ะปัŒะบั–ัั‚ัŽ ะทะฐะณะธะฑะปะธั…. ะะฐั‚ะธัะฝั–ั‚ัŒ ะผะฐั€ะบะตั€, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ ะบะฐั€ั‚ะบัƒ." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6125315f2..90684d7d5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3029,5 +3029,35 @@ "The provider rejected the API key. Check it in Provider Settings.": "ๆœๅŠกๅ•†ๆ‹’็ปไบ† API ๅฏ†้’ฅใ€‚่ฏทๅœจๆœๅŠกๅ•†่ฎพ็ฝฎไธญๆฃ€ๆŸฅใ€‚", "Too many chat requests. Try again in {seconds} s.": "่Šๅคฉ่ฏทๆฑ‚่ฟ‡ๅคšใ€‚่ฏทๅœจ {seconds} ็ง’ๅŽ้‡่ฏ•ใ€‚", "The provider refused access for this key. Check the key's project, region or permissions.": "ๆœๅŠกๅ•†ๆ‹’็ปไบ†ๆญคๅฏ†้’ฅ็š„่ฎฟ้—ฎ่ฏทๆฑ‚ใ€‚่ฏทๆฃ€ๆŸฅ่ฏฅๅฏ†้’ฅๆ‰€ๅฑž็š„้กน็›ฎใ€ๅŒบๅŸŸๆˆ–ๆƒ้™ใ€‚", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ๆœๅŠกๅ•†่ดฆๆˆทไฝ™้ขไธ่ถณใ€‚่ฏทๆฃ€ๆŸฅ่ดฆๆˆท็š„่ฎก่ดนไฟกๆฏ๏ผŒๆˆ–ๅœจ่ฎพ็ฝฎไธญ้€‰ๆ‹ฉๅ…ถไป– AI ๆœๅŠกๅ•†ใ€‚" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ๆœๅŠกๅ•†่ดฆๆˆทไฝ™้ขไธ่ถณใ€‚่ฏทๆฃ€ๆŸฅ่ดฆๆˆท็š„่ฎก่ดนไฟกๆฏ๏ผŒๆˆ–ๅœจ่ฎพ็ฝฎไธญ้€‰ๆ‹ฉๅ…ถไป– AI ๆœๅŠกๅ•†ใ€‚", + "Shipwrecks": "ๆฒ‰่ˆน", + "Before 1900": "1900ๅนดไปฅๅ‰", + "After 1945": "1945ๅนดไปฅๅŽ", + "Date unknown": "ๆ—ฅๆœŸไธ่ฏฆ", + "Position: centre of the protected area": "ไฝ็ฝฎ๏ผšไฟๆŠคๅŒบไธญๅฟƒ", + "Position approximate (within about {km} km)": "ๅคง่‡ดไฝ็ฝฎ๏ผˆ่ฏฏๅทฎ็บฆ {km} ๅ…ฌ้‡Œ๏ผ‰", + "Position surveyed (within about {m} m)": "ๅฎžๆต‹ไฝ็ฝฎ๏ผˆ่ฏฏๅทฎ็บฆ {m} ็ฑณ๏ผ‰", + "Vessel: {value}": "่ˆนๅช๏ผš{value}", + "What happened: {value}": "็ป่ฟ‡๏ผš{value}", + "Depth: {m} m ({quality})": "ๆทฑๅบฆ๏ผš{m} ็ฑณ๏ผˆ{quality}๏ผ‰", + "Depth: {m} m": "ๆทฑๅบฆ๏ผš{m} ็ฑณ", + "Notes: {value}": "ๅค‡ๆณจ๏ผš{value}", + "Cargo or finds: {value}": "่ดง็‰ฉๆˆ–ๅ‡บๆฐดๆ–‡็‰ฉ๏ผš{value}", + "Protected wreck": "ๅ—ไฟๆŠคๆฒ‰่ˆน", + "Source: {value}": "ๆฅๆบ๏ผš{value}", + "Click the card to open EMODnet โ†—": "็‚นๅ‡ปๅก็‰‡ๆ‰“ๅผ€ EMODnet โ†—", + "Click the card to open the source โ†—": "็‚นๅ‡ปๅก็‰‡ๆ‰“ๅผ€ๆฅๆบ โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata ไธๅฏ็”จ๏ผ›ไป…ๆ˜พ็คบ EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet ไธๅฏ็”จ๏ผ›ไป…ๆ˜พ็คบ Wikidata", + "Shipwrecks unavailable": "ๆฒ‰่ˆนๆ•ฐๆฎไธๅฏ็”จ", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet๏ผˆๆฌงๆดฒๆตทๅŸŸ๏ผ‰ยท Wikidata๏ผˆๅ…จ็ƒ๏ผ‰", + "{count} wrecks": { + "other": "{count} ่‰˜ๆฒ‰่ˆน" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "ๆฒ‰่ˆน", + "Fatalities: {count}": "้‡้šพ๏ผš{count}", + "Survivors: {count}": "ๅนธๅญ˜่€…๏ผš{count}", + "Click the card to open the article โ†—": "็‚นๅ‡ปๅก็‰‡ๆ‰“ๅผ€ๆ–‡็ซ  โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ๆฌงๆดฒๅކๅฒๆฒ‰่ˆนๆฅ่‡ช EMODnet Human Activities๏ผ›ๅ…จ็ƒ็Ÿฅๅๆฒ‰่ˆนๆฅ่‡ช Wikidataใ€‚้ขœ่‰ฒ่กจ็คบ่ˆนๅชๆฒ‰ๆฒก็š„ๆ—ถๆœŸ๏ผŒๆ ‡่ฎฐๅคงๅฐ้š้‡้šพไบบๆ•ฐๅขžๅŠ ใ€‚็‚นๅ‡ปๆ ‡่ฎฐๆŸฅ็œ‹ๅก็‰‡ใ€‚" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 66eb37b96..b1cbd78b3 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3029,5 +3029,35 @@ "The provider rejected the API key. Check it in Provider Settings.": "ๆœๅ‹™ๅ•†ๆ‹’็ต•ไบ† API ๅฏ†้‘ฐใ€‚่ซ‹ๅœจๆœๅ‹™ๅ•†่จญ็ฝฎไธญๆชขๆŸฅใ€‚", "Too many chat requests. Try again in {seconds} s.": "่Šๅคฉ่ซ‹ๆฑ‚้Žๅคšใ€‚่ซ‹ๅœจ {seconds} ็ง’ๅพŒ้‡่ฉฆใ€‚", "The provider refused access for this key. Check the key's project, region or permissions.": "ๆœๅ‹™ๅ•†ๆ‹’็ต•ไบ†ๆญคๅฏ†้‘ฐ็š„่จชๅ•่ซ‹ๆฑ‚ใ€‚่ซ‹ๆชขๆŸฅ่ฉฒๅฏ†้‘ฐๆ‰€ๅฑฌ็š„้ …็›ฎใ€ๅ€ๅŸŸๆˆ–ๆฌŠ้™ใ€‚", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ๆœๅ‹™ๅ•†่ณฌๆˆถ้ค˜้กไธ่ถณใ€‚่ซ‹ๆชขๆŸฅ่ณฌๆˆถ็š„่จˆ่ฒปไฟกๆฏ๏ผŒๆˆ–ๅœจ่จญ็ฝฎไธญ้ธๆ“‡ๅ…ถไป– AI ๆœๅ‹™ๅ•†ใ€‚" + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "ๆœๅ‹™ๅ•†่ณฌๆˆถ้ค˜้กไธ่ถณใ€‚่ซ‹ๆชขๆŸฅ่ณฌๆˆถ็š„่จˆ่ฒปไฟกๆฏ๏ผŒๆˆ–ๅœจ่จญ็ฝฎไธญ้ธๆ“‡ๅ…ถไป– AI ๆœๅ‹™ๅ•†ใ€‚", + "Shipwrecks": "ๆฒ‰่ˆน", + "Before 1900": "1900ๅนดไปฅๅ‰", + "After 1945": "1945ๅนดไปฅๅพŒ", + "Date unknown": "ๆ—ฅๆœŸไธ่ฉณ", + "Position: centre of the protected area": "ไฝ็ฝฎ๏ผšไฟ่ญทๅ€ไธญๅฟƒ", + "Position approximate (within about {km} km)": "ๅคง่‡ดไฝ็ฝฎ๏ผˆ่ชคๅทฎ็ด„ {km} ๅ…ฌ้‡Œ๏ผ‰", + "Position surveyed (within about {m} m)": "ๅฏฆๆธฌไฝ็ฝฎ๏ผˆ่ชคๅทฎ็ด„ {m} ๅ…ฌๅฐบ๏ผ‰", + "Vessel: {value}": "่ˆน้šป๏ผš{value}", + "What happened: {value}": "็ถ“้Ž๏ผš{value}", + "Depth: {m} m ({quality})": "ๆทฑๅบฆ๏ผš{m} ๅ…ฌๅฐบ๏ผˆ{quality}๏ผ‰", + "Depth: {m} m": "ๆทฑๅบฆ๏ผš{m} ๅ…ฌๅฐบ", + "Notes: {value}": "ๅ‚™่จป๏ผš{value}", + "Cargo or finds: {value}": "่ฒจ็‰ฉๆˆ–ๅ‡บๆฐดๆ–‡็‰ฉ๏ผš{value}", + "Protected wreck": "ๅ—ไฟ่ญทๆฒ‰่ˆน", + "Source: {value}": "ไพ†ๆบ๏ผš{value}", + "Click the card to open EMODnet โ†—": "้ปžๆ“Šๅก็‰‡้–‹ๅ•Ÿ EMODnet โ†—", + "Click the card to open the source โ†—": "้ปžๆ“Šๅก็‰‡้–‹ๅ•Ÿไพ†ๆบ โ†—", + "Wikidata unavailable; showing EMODnet only": "Wikidata ็„กๆณ•ไฝฟ็”จ๏ผ›ๅƒ…้กฏ็คบ EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet ็„กๆณ•ไฝฟ็”จ๏ผ›ๅƒ…้กฏ็คบ Wikidata", + "Shipwrecks unavailable": "ๆฒ‰่ˆน่ณ‡ๆ–™็„กๆณ•ไฝฟ็”จ", + "EMODnet (European waters) ยท Wikidata (worldwide)": "EMODnet๏ผˆๆญๆดฒๆตทๅŸŸ๏ผ‰ยท Wikidata๏ผˆๅ…จ็ƒ๏ผ‰", + "{count} wrecks": { + "other": "{count} ่‰˜ๆฒ‰่ˆน" + }, + "1900โ€“1945": "1900โ€“1945", + "Shipwreck": "ๆฒ‰่ˆน", + "Fatalities: {count}": "็ฝน้›ฃ๏ผš{count}", + "Survivors: {count}": "ๅ€–ๅญ˜่€…๏ผš{count}", + "Click the card to open the article โ†—": "้ปžๆ“Šๅก็‰‡้–‹ๅ•Ÿๆ–‡็ซ  โ†—", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ๆญๆดฒๆญทๅฒๆฒ‰่ˆนไพ†่‡ช EMODnet Human Activities๏ผ›ๅ…จ็ƒ็Ÿฅๅๆฒ‰่ˆนไพ†่‡ช Wikidataใ€‚้ก่‰ฒ่กจ็คบ่ˆน้šปๆฒ‰ๆฒ’็š„ๆ™‚ๆœŸ๏ผŒๆจ™่จ˜ๅคงๅฐ้šจ็ฝน้›ฃไบบๆ•ธๅขžๅŠ ใ€‚้ปžๆ“Šๆจ™่จ˜ๆŸฅ็œ‹ๅก็‰‡ใ€‚" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 7edaad647..3fd8eea8f 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1,6 +1,7 @@ { "ar": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -35,6 +36,7 @@ ], "de": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -206,6 +208,7 @@ ], "es": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -338,6 +341,7 @@ ], "fr": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "ALTITUDE", "ALTITUDE ยท FT", @@ -523,6 +527,7 @@ ], "it": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AFRICA", "AIS: --", @@ -698,6 +703,7 @@ ], "ja": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -748,6 +754,7 @@ ], "pt": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -880,6 +887,7 @@ "โ‰ฅ {value} MW" ], "ru": [ + "1900โ€“1945", "AIS: --", "AIS: {name}", "CCTV", @@ -898,6 +906,7 @@ "WHOT/BHOT" ], "uk": [ + "1900โ€“1945", "AIS: --", "AIS: {name}", "CCTV", @@ -912,6 +921,7 @@ ], "zh-CN": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", @@ -970,6 +980,7 @@ ], "zh-Hant": [ "1090 MHz", + "1900โ€“1945", "978 MHz UAT", "AIS: --", "AIS: {name}", diff --git a/src/layers/shipwrecks/cards.js b/src/layers/shipwrecks/cards.js new file mode 100644 index 000000000..251530b96 --- /dev/null +++ b/src/layers/shipwrecks/cards.js @@ -0,0 +1,123 @@ +import { formatDate, formatNumber, msg, t } from '../../i18n/index.js'; +import { EMODNET_PAGE_URL } from './records.js'; + +export const ERA_LABELS = Object.freeze({ + 'before-1900': msg('Before 1900'), + '1900-1945': msg('1900โ€“1945'), + 'after-1945': msg('After 1945'), + undated: msg('Date unknown'), +}); + +export const ERA_COLORS = Object.freeze({ + 'before-1900': '#c79a5b', + '1900-1945': '#ff6b5e', + 'after-1945': '#4fc3f7', + undated: '#9aa7b4', +}); + +function formatDay(date) { + const match = /^(-?\d{4})-(\d{2})-(\d{2})$/.exec(date || ''); + if (!match) return ''; + return formatDate( + Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])), + { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }, + ); +} + +function meters(value) { + return formatNumber(value, { + maximumFractionDigits: value < 10 ? 1 : 0, + }); +} + +/** How well the position is known, as one card line (or null). */ +export function positionLine(record) { + if (record.positionNote) return t('Position: centre of the protected area'); + const accuracy = record.positionAccuracy; + if (!Number.isFinite(accuracy)) return null; + // 1852 m is EMODnet's "within a nautical mile" for old, reported positions. + if (accuracy >= 1000) + return t('Position approximate (within about {km} km)', { + km: formatNumber(accuracy / 1000, { maximumFractionDigits: 1 }), + }); + return t('Position surveyed (within about {m} m)', { m: meters(accuracy) }); +} + +/** + * The link a click on the card opens: the Wikipedia (or Wikidata) page, the + * wreck's own page when EMODnet lists one, else EMODnet's portal. + * @param {object} record + * @returns {string} + */ +export function cardLink(record) { + return record?.url || EMODNET_PAGE_URL; +} + +/** + * Title and detail lines for one wreck card, laid out like the air-accident + * card: deaths first, then when and where, then the ship facts. Only facts + * about the ship: the records carry no names of people, and victims appear as + * a count only. + * @param {object} record A normalized shipwreck record. + * @returns {{title: string, details: string[], link: string}} + */ +export function describeShipwreck(record) { + const titleParts = [t('Shipwreck')]; + if (record.name) titleParts.push(record.name); + const details = []; + // Deaths first, right under the title: the fact readers look for. + const toll = []; + if (Number.isFinite(record.casualties)) + toll.push( + t('Fatalities: {count}', { count: formatNumber(record.casualties) }), + ); + if (record.survivors) + toll.push( + t('Survivors: {count}', { count: formatNumber(record.survivors) }), + ); + if (toll.length) details.push(toll.join(' ยท ')); + const when = + formatDay(record.date) || + (Number.isInteger(record.year) ? String(record.year) : '') || + record.dating || + ''; + const where = [when, record.country].filter(Boolean); + if (where.length) details.push(where.join(' ยท ')); + if (record.vesselType) + details.push(t('Vessel: {value}', { value: record.vesselType })); + if (record.cause) + details.push(t('What happened: {value}', { value: record.cause })); + if (Number.isFinite(record.depth)) + details.push( + record.depthQuality + ? t('Depth: {m} m ({quality})', { + m: meters(record.depth), + quality: record.depthQuality, + }) + : t('Depth: {m} m', { m: meters(record.depth) }), + ); + const position = positionLine(record); + if (position) details.push(position); + if (record.notes) details.push(t('Notes: {value}', { value: record.notes })); + if (record.cargo) + details.push(t('Cargo or finds: {value}', { value: record.cargo })); + if (record.protected) details.push(t('Protected wreck')); + const sources = + record.source === 'wikidata' + ? record.alsoIn === 'emodnet' + ? 'Wikidata ยท EMODnet' + : 'Wikidata' + : record.originator + ? `EMODnet (${record.originator})` + : 'EMODnet'; + details.push(t('Source: {value}', { value: sources })); + const link = cardLink(record); + details.push( + link === EMODNET_PAGE_URL + ? t('Click the card to open EMODnet โ†—') + : link.includes('.wikipedia.org/') + ? t('Click the card to open the article โ†—') + : t('Click the card to open the source โ†—'), + ); + return { title: titleParts.join(' ยท '), details, link }; +} diff --git a/src/layers/shipwrecks/cards.test.mjs b/src/layers/shipwrecks/cards.test.mjs new file mode 100644 index 000000000..6b7c84c62 --- /dev/null +++ b/src/layers/shipwrecks/cards.test.mjs @@ -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/, + ); +}); diff --git a/src/layers/shipwrecks/eras.js b/src/layers/shipwrecks/eras.js new file mode 100644 index 000000000..4e9c2a1ff --- /dev/null +++ b/src/layers/shipwrecks/eras.js @@ -0,0 +1,33 @@ +/** Era chips, oldest first; a wreck with no known year sits in its own chip. */ +export const SHIPWRECK_ERAS = Object.freeze([ + 'before-1900', + '1900-1945', + 'after-1945', + 'undated', +]); + +/** + * The era a wreck belongs to. Ancient wrecks dated only by period + * ("Roman Imperial", "C1st AD") count as before 1900. + * @param {{year?: number|null, ancient?: boolean}} record + * @returns {string} + */ +export function eraOf(record) { + const year = Number.isInteger(record?.year) ? record.year : null; + if (year === null) return record?.ancient ? 'before-1900' : 'undated'; + if (year < 1900) return 'before-1900'; + if (year <= 1945) return '1900-1945'; + return 'after-1945'; +} + +/** + * Normalize a requested set of eras; unknown names are ignored and an empty + * or missing request keeps the current set. + * @param {unknown} value Array of era ids. + * @param {Set} current + * @returns {Set} + */ +export function normalizeEras(value, current) { + if (!Array.isArray(value)) return new Set(current); + return new Set(value.filter((era) => SHIPWRECK_ERAS.includes(era))); +} diff --git a/src/layers/shipwrecks/eras.test.mjs b/src/layers/shipwrecks/eras.test.mjs new file mode 100644 index 000000000..3891d60e7 --- /dev/null +++ b/src/layers/shipwrecks/eras.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SHIPWRECK_ERAS, eraOf, normalizeEras } from './eras.js'; + +test('each wreck falls in one era', () => { + assert.equal(eraOf({ year: 1588 }), 'before-1900'); + assert.equal(eraOf({ year: null, ancient: true }), 'before-1900'); + assert.equal(eraOf({ year: 1900 }), '1900-1945'); + assert.equal(eraOf({ year: 1945 }), '1900-1945'); + assert.equal(eraOf({ year: 1946 }), 'after-1945'); + assert.equal(eraOf({ year: null }), 'undated'); + assert.equal(eraOf(null), 'undated'); +}); + +test('era requests keep known eras only', () => { + const all = new Set(SHIPWRECK_ERAS); + assert.deepEqual( + [...normalizeEras(['after-1945', 'bogus'], all)], + ['after-1945'], + ); + assert.deepEqual([...normalizeEras('after-1945', all)], SHIPWRECK_ERAS); + assert.deepEqual([...normalizeEras([], all)], []); +}); diff --git a/src/layers/shipwrecks/index.js b/src/layers/shipwrecks/index.js new file mode 100644 index 000000000..05c2c64a4 --- /dev/null +++ b/src/layers/shipwrecks/index.js @@ -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 = + `` + + `` + + `` + + `` + + ``; + 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; +} diff --git a/src/layers/shipwrecks/records.js b/src/layers/shipwrecks/records.js new file mode 100644 index 000000000..7a7072205 --- /dev/null +++ b/src/layers/shipwrecks/records.js @@ -0,0 +1,428 @@ +// Shared by the server proxy (server/providers/shipwrecks.js) and the browser +// layer, so it must not load the i18n catalogs: values stay data. + +/** EMODnet Human Activities "Cultural Heritage โ€“ Ship wrecks" (CC BY 4.0). */ +export const EMODNET_WFS_URL = + 'https://ows.emodnet-humanactivities.eu/wfs?service=WFS&request=GetFeature' + + '&version=2.0.0&typeNames=emodnet:heritageshipwrecks' + + '&outputFormat=application/json&srsName=EPSG:4326'; +/** Landing page shown when an EMODnet wreck has no web page of its own. */ +export const EMODNET_PAGE_URL = + 'https://emodnet.ec.europa.eu/en/human-activities'; + +export const SHIPWRECK_SOURCES = Object.freeze(['emodnet', 'wikidata']); + +/** + * Wikidata (CC0): shipwreck items that have at least one Wikipedia article, + * so the worldwide set stays to notable wrecks and skips bulk register + * imports, plus sinking events ("sinking of the RMS Titanic"), which are where + * Wikidata keeps the death toll. Dates come with their precision so a + * month-precise date is not shown as the 1st. Only ship and event facts are + * read: no items about people. + */ +export const WIKIDATA_QUERY = `SELECT ?item ?itemLabel ?itemDescription ?coord ?sunk ?sunkPrecision ?end ?deaths ?survivors ?typeLabel ?enwiki ?ptwiki ?event WHERE { + { + ?item wdt:P31/wdt:P279* wd:Q852190 ; wdt:P625 ?coord ; wikibase:sitelinks ?links . + FILTER(?links > 0) + OPTIONAL { ?item p:P793 ?wreck . ?wreck ps:P793 wd:Q906512 ; pqv:P585 [ wikibase:timeValue ?sunk ; wikibase:timePrecision ?sunkPrecision ] } + OPTIONAL { ?item wdt:P576 ?end } + OPTIONAL { ?item wdt:P31 ?type . FILTER(?type != wd:Q852190) } + } UNION { + ?item wdt:P31/wdt:P279* wd:Q906512 ; wdt:P625 ?coord . + BIND("event" AS ?event) + OPTIONAL { ?item p:P585/psv:P585 [ wikibase:timeValue ?sunk ; wikibase:timePrecision ?sunkPrecision ] } + OPTIONAL { ?item wdt:P1561 ?survivors } + } + OPTIONAL { ?item wdt:P1120 ?deaths } + OPTIONAL { ?enwiki schema:about ?item ; schema:isPartOf } + OPTIONAL { ?ptwiki schema:about ?item ; schema:isPartOf } + SERVICE wikibase:label { bd:serviceParam wikibase:language "en,pt,mul". } +}`; + +const MAX_TEXT = 160; +const MAX_NOTE = 260; +/** Same name within this distance: one wreck seen by both sources. */ +const SAME_WRECK_KM = 3; + +// Free text sometimes names people (owners, masters, divers who reported a +// wreck). The layer shows ships, never people, so such text is dropped whole. +const PERSON_TEXT = + /\b(mr|mrs|ms|dr|capt|captain|master|skipper|owner|owned|crew|body|bodies|drowned|reported by|according to)\b/i; + +/** EMODnet writes "n/a" or nothing where a value is unknown. */ +function clean(value, max = MAX_TEXT) { + if (typeof value !== 'string') return null; + const text = value + .replace(/[\u0000-\u001f<>]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text || /^(n\/a|null|unknown|none|-)$/i.test(text)) return null; + return text.length > max ? `${text.slice(0, max - 1).trimEnd()}โ€ฆ` : text; +} + +/** Free text with no personal names in it, or null. */ +function impersonalText(value, max) { + const text = clean(value, max); + return text && !PERSON_TEXT.test(text) ? text : null; +} + +function positiveNumber(value, max) { + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) && n > 0 && n <= max ? n : null; +} + +function count(value) { + const text = String(value ?? '').replace(/\.0+$/, ''); + if (!/^\d{1,6}$/.test(text)) return null; + return Number(text); +} + +/** A sinking year from "1917", "1917-1918" or "c. 1917"; null otherwise. */ +export function parseYear(value, maxYear = new Date().getUTCFullYear()) { + const match = /(?:^|\D)(\d{3,4})(?:\D|$)/.exec(String(value ?? '')); + if (!match) return null; + const year = Number(match[1]); + return year >= 100 && year <= maxYear ? year : null; +} + +function validPoint(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 && + !(lat === 0 && lon === 0) + ); +} + +function httpUrl(value) { + const text = clean(value, 400); + if (!text) return null; + try { + const url = new URL(text); + return url.protocol === 'https:' || url.protocol === 'http:' + ? url.href + : null; + } catch { + return null; + } +} + +/** + * Normalize EMODnet's shipwreck GeoJSON. Records the portal took from + * wrecksite.eu are skipped: that site's data is not openly licensed. + * @param {object} payload WFS GeoJSON FeatureCollection. + * @returns {object[]} Shipwreck records. + */ +export function normalizeEmodnetFeatures(payload) { + const features = payload?.features; + if (!Array.isArray(features)) return []; + const out = []; + const ids = new Set(); + for (const feature of features) { + const p = feature?.properties || {}; + const coords = feature?.geometry?.coordinates; + if (feature?.geometry?.type !== 'Point' || !Array.isArray(coords)) continue; + const lon = Number(coords[0]); + const lat = Number(coords[1]); + if (!validPoint(lat, lon)) continue; + const origin = clean(p.source_inf, 80); + if (origin && /wrecksite/i.test(origin)) continue; + const key = clean(String(feature.id ?? p.source_id ?? ''), 80); + if (!key) continue; + const id = `emodnet:${key}`; + if (ids.has(id)) continue; + ids.add(id); + const year = parseYear(p.sink_yr); + const dating = clean(p.dating, 60); + const period = clean(p.period, 60); + const vesselType = + clean(p.obj_type, 60) || + (() => { + // ship_char is sometimes a short type ("Trawler"), sometimes a long + // history that can name people; only the short form is a type. + const text = clean(p.ship_char, 40); + return text && !/\d/.test(text) && !PERSON_TEXT.test(text) + ? text + : null; + })(); + out.push({ + id, + source: 'emodnet', + lat, + lon, + name: clean(p.name, 100), + vesselType, + year, + date: null, + dating: year ? null : dating, + ancient: !year && Boolean(period || dating), + cause: impersonalText(p.sink_context, MAX_NOTE), + notes: period && dating ? `${period} (${dating})` : period, + cargo: impersonalText(p.artefacts, 120), + protected: /protected/i.test(p.statutory || ''), + depth: positiveNumber(p.least_depth, 11000), + depthQuality: clean(p.depth_prec, 80), + positionAccuracy: positiveNumber(p.loc_prec, 100000), + positionNote: /centroid/i.test(p.point_info || '') + ? 'Centre of the protected area' + : null, + casualties: null, + survivors: null, + country: clean(p.country, 60), + originator: origin, + url: httpUrl(p.website1), + }); + } + return out; +} + +function wikiUrl(value) { + const url = httpUrl(value); + return url && /^https:\/\/[a-z]{2,3}\.wikipedia\.org\/wiki\//.test(url) + ? url + : null; +} + +/** + * A Wikidata time as a day ("1912-04-15") when it is day-precise, else as a + * year only: a month- or year-precise value reads back as the 1st. + * @returns {{date: string|null, year: number|null}} + */ +function wikidataTime(value, precision) { + const match = /^(-?\d{4})-(\d{2})-(\d{2})T/.exec(value || ''); + if (!match) return { date: null, year: null }; + const year = parseYear(match[1]); + const p = Number(precision); + // 11 = day; 9 = year; 10 = month. A missing precision (P576) counts as a day. + if (!Number.isFinite(p) || p >= 11) + return { date: `${match[1]}-${match[2]}-${match[3]}`, year }; + return { date: null, year: p >= 9 ? year : null }; +} + +/** "sinking of the RMS Titanic" โ†’ "Sinking of the RMS Titanic". */ +function sentenceCase(text) { + return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; +} + +/** + * Normalize Wikidata SPARQL results to shipwreck records (one per item). + * Sinking events are folded into the ship they sank when both are listed. + * @param {object} payload SPARQL JSON results. + * @returns {object[]} + */ +export function normalizeWikidataResults(payload) { + const bindings = payload?.results?.bindings; + if (!Array.isArray(bindings)) return []; + const byItem = new Map(); + for (const binding of bindings) { + const value = (key) => binding?.[key]?.value; + const qid = /\/entity\/(Q\d{1,12})$/.exec(value('item') || '')?.[1]; + if (!qid) continue; + const sunk = wikidataTime(value('sunk'), value('sunkPrecision')); + const ended = wikidataTime(value('end'), null); + const known = byItem.get(qid); + if (known) { + // Extra rows only add what the first one lacked. + known.vesselType ||= clean(value('typeLabel'), 60); + if (!known.date && !known.year) { + known.date = sunk.date || ended.date; + known.year = sunk.year ?? ended.year; + } + known.casualties ??= count(value('deaths')); + known.survivors ??= count(value('survivors')); + continue; + } + const point = /^Point\(([-\d.eE]+) ([-\d.eE]+)\)$/.exec( + value('coord') || '', + ); + const lon = point ? Number(point[1]) : NaN; + const lat = point ? Number(point[2]) : NaN; + if (!validPoint(lat, lon)) continue; + const name = clean(value('itemLabel'), 100); + const event = value('event') === 'event'; + const when = sunk.date || sunk.year ? sunk : ended; + byItem.set(qid, { + id: `wikidata:${qid}`, + source: 'wikidata', + event, + lat, + lon, + // An unlabelled item reads back as its own Q-id; that is no name. + name: name && name !== qid ? (event ? sentenceCase(name) : name) : null, + vesselType: event ? null : clean(value('typeLabel'), 60), + year: when.year, + date: when.date, + dating: null, + ancient: false, + cause: null, + notes: impersonalText(value('itemDescription'), MAX_TEXT), + cargo: null, + protected: false, + depth: null, + depthQuality: null, + positionAccuracy: null, + positionNote: null, + casualties: count(value('deaths')), + survivors: count(value('survivors')), + country: null, + originator: null, + url: + wikiUrl(value('enwiki')) || + wikiUrl(value('ptwiki')) || + `https://www.wikidata.org/wiki/${qid}`, + }); + } + return foldSinkingEvents([...byItem.values()]); +} + +/** An event this far from a listed ship whose name it contains is that ship's sinking. */ +const SAME_SINKING_KM = 50; + +/** + * "Sinking of the RMS Titanic" near the RMS Titanic wreck is one marker: the + * ship keeps its article and takes the event's date, deaths and survivors. + */ +function foldSinkingEvents(records) { + // Wikidata sometimes has both the ship and its wreck as items at one spot + // ("Titanic", "wreck of the RMS Titanic"): keep the ship, one marker. + const isWreckItem = (record) => (/^wreck\b/i.test(record.name || '') ? 1 : 0); + const ships = []; + const candidates = records + .filter((record) => !record.event) + .sort((a, b) => isWreckItem(a) - isWreckItem(b)); + for (const record of candidates) { + const key = nameKey(record.name); + const twin = + key && + ships.find( + (ship) => + nameKey(ship.name) === key && + distanceKm(ship, record) <= SAME_WRECK_KM, + ); + if (!twin) { + ships.push(record); + continue; + } + twin.vesselType ||= record.vesselType; + if (!twin.date && !twin.year) { + twin.date = record.date; + twin.year = record.year; + } + twin.casualties ??= record.casualties; + twin.survivors ??= record.survivors; + } + const out = [...ships]; + for (const event of records.filter((record) => record.event)) { + const eventKey = ` ${nameKey(event.name)} `; + const ship = ships.find((candidate) => { + const key = nameKey(candidate.name); + return ( + key.length >= 3 && + eventKey.includes(` ${key} `) && + distanceKm(candidate, event) <= SAME_SINKING_KM + ); + }); + if (!ship) { + out.push(event); + continue; + } + if (!ship.date && !ship.year) { + ship.date = event.date; + ship.year = event.year; + } else if (!ship.date && event.date && event.year === ship.year) { + ship.date = event.date; + } + ship.casualties ??= event.casualties; + ship.survivors ??= event.survivors; + } + for (const record of out) delete record.event; + return out; +} + +function nameKey(name) { + return String(name || '') + .normalize('NFD') + .replace(/[ฬ€-อฏ]/g, '') + .toLowerCase() + .replace(/\([^)]*\)/g, ' ') + .replace(/\b(wreck|sinking|loss|disaster|of)\b/g, ' ') + .replace( + /\b(hms|hmt|hmhs|hmas|sms|ss|ms|mv|rms|uss|hm|s\/s|m\/v|the)\b/g, + ' ', + ) + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function distanceKm(a, b) { + const rad = Math.PI / 180; + const dLat = (b.lat - a.lat) * rad; + const dLon = (b.lon - a.lon) * rad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; + return 12742 * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** + * One list from both sources. A wreck in both (same name, a few km apart) + * keeps the Wikidata record, which links an article, and takes EMODnet's + * survey facts (depth, sinking text, year) that Wikidata lacks. + */ +export function mergeShipwreckRecords(emodnet = [], wikidata = []) { + const byName = new Map(); + for (const record of wikidata) { + const key = nameKey(record.name); + if (!key) continue; + if (!byName.has(key)) byName.set(key, []); + byName.get(key).push(record); + } + const kept = []; + for (const record of emodnet) { + const twin = (byName.get(nameKey(record.name)) || []).find( + (candidate) => distanceKm(candidate, record) <= SAME_WRECK_KM, + ); + if (!twin) { + kept.push(record); + continue; + } + twin.year ??= record.year; + twin.vesselType ||= record.vesselType; + twin.cause ||= record.cause; + twin.depth ??= record.depth; + twin.depthQuality ||= record.depthQuality; + twin.positionAccuracy ??= record.positionAccuracy; + twin.country ||= record.country; + twin.protected ||= record.protected; + twin.alsoIn = 'emodnet'; + } + return [...wikidata, ...kept]; +} + +/** + * Validate the proxy's snapshot in the browser. + * @returns {object[]|null} Records, or null when the payload is malformed. + */ +export function normalizeShipwreckSnapshot(payload) { + if (!Array.isArray(payload?.records)) return null; + const rows = []; + const ids = new Set(); + for (const record of payload.records) { + if ( + !record || + typeof record.id !== 'string' || + ids.has(record.id) || + !SHIPWRECK_SOURCES.includes(record.source) || + !validPoint(record.lat, record.lon) + ) + continue; + ids.add(record.id); + rows.push({ + ...record, + year: Number.isInteger(record.year) ? record.year : null, + url: httpUrl(record.url), + }); + } + return rows; +} diff --git a/src/layers/shipwrecks/records.test.mjs b/src/layers/shipwrecks/records.test.mjs new file mode 100644 index 000000000..a61154a5a --- /dev/null +++ b/src/layers/shipwrecks/records.test.mjs @@ -0,0 +1,212 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mergeShipwreckRecords, + normalizeEmodnetFeatures, + normalizeShipwreckSnapshot, + normalizeWikidataResults, + parseYear, +} from './records.js'; + +function feature(id, properties, coordinates = [-4.35, 54.13]) { + return { + type: 'Feature', + id, + geometry: { type: 'Point', coordinates }, + properties, + }; +} + +test('EMODnet wrecks keep ship facts and drop text that names people', () => { + const [wreck, roman] = normalizeEmodnetFeatures({ + features: [ + feature('w.1', { + name: 'MAINA-NUI', + sink_yr: '1987', + sink_context: 'Sank on 07/02/1987', + obj_type: 'Pusher or tug', + least_depth: 12.5, + depth_prec: 'Least depth known', + loc_prec: 50, + country: 'France', + source_inf: 'SHOM', + statutory: 'n/a', + website1: 'n/a', + }), + feature('w.2', { + name: 'Lerici', + sink_yr: '', + dating: 'C1st-2nd AD', + period: 'Roman Imperial', + artefacts: 'Marble, Columns', + obj_type: 'n/a', + ship_char: 'Owned by J. Smith, master was A. Jones', + sink_context: 'Wrecked here, reported by Mr Smith', + }), + feature('w.3', { name: 'Copied', source_inf: 'www.wrecksite.eu' }), + feature('w.4', { name: 'Null island' }, [0, 0]), + ], + }); + assert.equal(wreck.id, 'emodnet:w.1'); + assert.equal(wreck.year, 1987); + assert.equal(wreck.vesselType, 'Pusher or tug'); + assert.equal(wreck.cause, 'Sank on 07/02/1987'); + assert.equal(wreck.depth, 12.5); + assert.equal(wreck.positionAccuracy, 50); + assert.equal(wreck.url, null); + assert.equal(roman.year, null); + assert.equal(roman.ancient, true); + assert.equal(roman.dating, 'C1st-2nd AD'); + assert.equal(roman.notes, 'Roman Imperial (C1st-2nd AD)'); + assert.equal(roman.cargo, 'Marble, Columns'); + assert.equal(roman.vesselType, null, 'a history naming people is no type'); + assert.equal(roman.cause, null, 'text naming a person is dropped'); +}); + +test('Wikidata rows fold into one record per item', () => { + const item = 'http://www.wikidata.org/entity/Q1471525'; + const records = normalizeWikidataResults({ + results: { + bindings: [ + { + item: { value: item }, + itemLabel: { value: 'HMS Havant' }, + itemDescription: { value: '1939 G and H-class destroyer' }, + coord: { value: 'Point(2.26 51.13)' }, + sunk: { value: '1940-06-01T00:00:00Z' }, + enwiki: { value: 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)' }, + }, + { + item: { value: item }, + typeLabel: { value: 'destroyer' }, + deaths: { value: '8' }, + coord: { value: 'Point(2.26 51.13)' }, + }, + { + item: { value: 'http://www.wikidata.org/entity/Q9' }, + itemLabel: { value: 'Q9' }, + coord: { value: 'Point(10 10)' }, + }, + ], + }, + }); + assert.equal(records.length, 2); + const [havant, unnamed] = records; + assert.equal(havant.date, '1940-06-01'); + assert.equal(havant.year, 1940); + assert.equal(havant.vesselType, 'destroyer'); + assert.equal(havant.casualties, 8); + assert.equal(havant.url, 'https://en.wikipedia.org/wiki/HMS_Havant_(H32)'); + assert.equal(unnamed.name, null); + assert.equal(unnamed.url, 'https://www.wikidata.org/wiki/Q9'); +}); + +test('a wreck in both sources keeps Wikidata and takes EMODnet survey facts', () => { + const wikidata = [ + { + id: 'wikidata:Q1', + source: 'wikidata', + name: 'HMS Havant', + lat: 51.13, + lon: 2.26, + year: null, + depth: null, + }, + ]; + const emodnet = [ + { + id: 'emodnet:a', + source: 'emodnet', + name: 'Havant (HMS)', + lat: 51.131, + lon: 2.262, + year: 1940, + depth: 14.7, + }, + { + id: 'emodnet:b', + source: 'emodnet', + name: 'HMS Havant', + lat: 45, + lon: -10, + year: 1950, + }, + ]; + const merged = mergeShipwreckRecords(emodnet, wikidata); + assert.deepEqual( + merged.map((record) => record.id), + ['wikidata:Q1', 'emodnet:b'], + ); + assert.equal(merged[0].year, 1940); + assert.equal(merged[0].depth, 14.7); + assert.equal(merged[0].alsoIn, 'emodnet'); +}); + +test('years and snapshots are validated', () => { + assert.equal(parseYear('1917-1918'), 1917); + assert.equal(parseYear('c. 1588'), 1588); + assert.equal(parseYear(''), null); + assert.equal(parseYear('3000'), null); + assert.equal(normalizeShipwreckSnapshot({}), null); + const rows = normalizeShipwreckSnapshot({ + records: [ + { id: 'a', source: 'emodnet', lat: 1, lon: 2, url: 'javascript:x' }, + { id: 'a', source: 'emodnet', lat: 1, lon: 2 }, + { id: 'b', source: 'other', lat: 1, lon: 2 }, + { id: 'c', source: 'wikidata', lat: 91, lon: 2 }, + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].url, null); +}); + +test('a sinking event folds into its ship; month-precise dates keep the year only', () => { + const entity = (q) => ({ value: `http://www.wikidata.org/entity/${q}` }); + const records = normalizeWikidataResults({ + results: { + bindings: [ + { + item: entity('Q25173'), + itemLabel: { value: 'Titanic' }, + coord: { value: 'Point(-49.9469 41.7325)' }, + }, + { + item: entity('Q3018259'), + itemLabel: { value: 'wreck of the RMS Titanic' }, + coord: { value: 'Point(-49.9469 41.7325)' }, + }, + { + item: entity('Q2577588'), + itemLabel: { value: 'sinking of the RMS Titanic' }, + coord: { value: 'Point(-49.9458 41.7319)' }, + event: { value: 'event' }, + sunk: { value: '1912-04-01T00:00:00Z' }, + sunkPrecision: { value: '10' }, + deaths: { value: '1501' }, + survivors: { value: '712' }, + }, + { + item: entity('Q1245004'), + itemLabel: { value: 'Costa Concordia disaster' }, + coord: { value: 'Point(10.92 42.37)' }, + event: { value: 'event' }, + sunk: { value: '2012-01-13T00:00:00Z' }, + sunkPrecision: { value: '11' }, + deaths: { value: '32' }, + }, + ], + }, + }); + assert.deepEqual( + records.map((record) => record.id), + ['wikidata:Q25173', 'wikidata:Q1245004'], + ); + const [titanic, concordia] = records; + assert.equal(titanic.date, null); + assert.equal(titanic.year, 1912); + assert.equal(titanic.casualties, 1501); + assert.equal(titanic.survivors, 712); + assert.equal(concordia.name, 'Costa Concordia disaster'); + assert.equal(concordia.date, '2012-01-13'); + assert.equal('event' in concordia, false); +}); diff --git a/src/layers/shipwrecks/source.js b/src/layers/shipwrecks/source.js new file mode 100644 index 000000000..49f75b526 --- /dev/null +++ b/src/layers/shipwrecks/source.js @@ -0,0 +1,35 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; +import { normalizeShipwreckSnapshot } from './records.js'; + +/** Same-origin source for the proxy's EMODnet + Wikidata shipwreck snapshot. */ +export function createShipwrecksSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/shipwrecks', { signal }); + if (!response.ok) + throw new Error(`Shipwrecks unavailable (HTTP ${response.status})`); + const payload = await readResponseJsonCapped( + response, + 16 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + const records = normalizeShipwreckSnapshot(payload); + if (!records) throw new Error('Malformed shipwrecks response'); + return { + records, + fetchedAt: Number.isFinite(payload.fetchedAt) + ? payload.fetchedAt + : null, + stale: payload.stale === true, + sources: { + emodnet: payload.sources?.emodnet !== false, + wikidata: payload.sources?.wikidata !== false, + }, + }; + }, + }; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 2e0534a3a..8f903510c 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -20,6 +20,7 @@ import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; +import { createShipwrecksSource } from '../layers/shipwrecks/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -60,6 +61,7 @@ export function createStandaloneLayerSources( alpr: createAlprTileSource(), firms: createFirmsSource(), 's2-ghostmaps': createGhostMapsSource(), + shipwrecks: createShipwrecksSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 21f681b11..617605035 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -37,6 +37,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'gnss-integrity': msg('GNSS Integrity'), 'drone-airspace': msg('Prohibited & Restricted Airspace'), 's2-ghostmaps': msg('S2 GhostMaps'), + shipwrecks: msg('Shipwrecks'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..4c16b047c 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -78,6 +78,7 @@ const PANEL_GROUPS = [ 'fire-perimeters', 'gnss-integrity', 's2-ghostmaps', + 'shipwrecks', ], }, { diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1234fb61e..b05aba33d 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -457,6 +457,8 @@ export const VOICE_OFF_LAYERS = Object.freeze({ // query for this layer wait until there is room. 'drone-airspace': 'informational airspace layer; no room in the prompt budget', + // Same budget: a historical wreck archive waits for room too. + shipwrecks: 'historical shipwreck archive; no room in the prompt budget', }); const TYPE_NAMES = Object.freeze({ From 112ffd4386c779e1286cb4144633a75a2fb9722d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:49:36 -0300 Subject: [PATCH 06/86] feat(shipwrecks): period picker like air accidents, sharper icons - Replace the four era chips with the start and end year pickers the air-accidents layer uses (the same RowRange control, ported as is); the era colours stay as a legend under the row. - Rasterize the boat marker at 144 px so large markers stay sharp. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 16 +++-- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 7 ++ src/i18n/locales/ar.json | 8 ++- src/i18n/locales/de.json | 8 ++- src/i18n/locales/es.json | 8 ++- src/i18n/locales/fr.json | 8 ++- src/i18n/locales/it.json | 8 ++- src/i18n/locales/ja.json | 8 ++- src/i18n/locales/pt.json | 8 ++- src/i18n/locales/ru.json | 8 ++- src/i18n/locales/uk.json | 8 ++- src/i18n/locales/zh-CN.json | 8 ++- src/i18n/locales/zh-Hant.json | 8 ++- src/layers/shipwrecks/eras.js | 12 ---- src/layers/shipwrecks/eras.test.mjs | 12 +--- src/layers/shipwrecks/index.js | 94 +++++++++++++++++++-------- src/layers/shipwrecks/period.js | 71 ++++++++++++++++++++ src/layers/shipwrecks/period.test.mjs | 38 +++++++++++ src/ui/layerPanel.js | 23 ++++++- src/ui/layerPanel/LayerRows.vue | 9 +++ src/ui/layerPanel/RowRange.vue | 40 ++++++++++++ src/ui/layerPanel/layerRows.test.mjs | 59 +++++++++++++++++ src/ui/styles/radio.css | 37 +++++++++++ 25 files changed, 439 insertions(+), 71 deletions(-) create mode 100644 src/layers/shipwrecks/period.js create mode 100644 src/layers/shipwrecks/period.test.mjs create mode 100644 src/ui/layerPanel/RowRange.vue diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 805771b67..6bababb81 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -40,7 +40,7 @@ How to read this: | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground ยฉ 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground ยฉ by S2A1, CC BY-NC-SA 4.0" โ€” shown in-app | | **EMODnet Human Activities โ€” Ship wrecks** (`ows.emodnet-humanactivities.eu/wfs`, layer `emodnet:heritageshipwrecks`) | Shipwrecks layer: about 7,000 wrecks in European waters (name, sinking year and text, vessel type, least depth and its quality, position accuracy, period, cargo, protected status). Keyless WFS GeoJSON, fetched by `/api/shipwrecks` once a day; rows sourced from wrecksite.eu are skipped and text naming people is dropped | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) (EMODnet catalogue record `e965088b-a265-4517-84df-c49b156af8a7`; data originator AND-International, compiled from SHOM, National Monuments Service, Historic England and the Oxford Roman Economy Project); fetched at runtime, not stored in this repository | "Shipwrecks (Europe): EMODnet Human Activities (data originator AND-International, โ€ฆ), CC BY 4.0" โ€” shown in-app | -| **Wikidata โ€” shipwrecks** (`query.wikidata.org/sparql`) | Shipwrecks layer: shipwreck items (Q852190) with a coordinate and at least one Wikipedia article: name, ship type, sinking date, death count, description and article link. Keyless, fetched by `/api/shipwrecks` once a day | [CC0](https://creativecommons.org/publicdomain/zero/1.0/); fetched at runtime | "Shipwrecks (worldwide): Wikidata (CC0)" โ€” shown in-app | +| **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" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap ยฉ OpenMapTiles | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e0df74333..dd7838b7c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -116,14 +116,18 @@ read: EMODnet's long histories (`ship_char`, `obj_desc`) are not, and any sinking or cargo text that names a person (owner, master, a diver who reported it) is dropped whole; deaths appear only as a Wikidata count. -The globe draws a small hull marker per wreck, ground-clamped with the depth -test off and the horizon cull GhostMaps uses, coloured by era: before 1900 +The globe draws a round marker with a hull and mast per wreck, ground-clamped +with the depth test off and the horizon cull GhostMaps uses, sized by deaths as +on Air accidents 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. Four row chips show or hide each era (session-only, not in the -share link). Clicking a marker opens a `stateless` card with the name, vessel -type, sinking date or year (or the archaeological dating), what happened, deaths, +date unknown. A period filter under the row picks a start year and an end year +side by side, the same control as Air accidents (two pickers over the years in +the data; a reversed pair is 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 laid out like the air-accident 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, waters and source. +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). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d3eb3d95b..16b5e1ac5 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1214,6 +1214,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", @@ -1271,5 +1272,6 @@ "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/data/shipwrecksProxy.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7460d06d2..024982e83 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -686,6 +686,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", @@ -1023,6 +1024,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", @@ -2812,6 +2814,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", @@ -2945,6 +2948,7 @@ "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", @@ -3702,6 +3706,7 @@ "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", @@ -4769,6 +4774,7 @@ "src/layers/shipwrecks/records.js", "src/layers/shipwrecks/cards.js", "src/layers/shipwrecks/eras.js", + "src/layers/shipwrecks/period.js", "src/sources/httpBody.js", "src/data/geoFileImport.js", "src/i18n/marker.js", @@ -4802,6 +4808,7 @@ "src/layers/shipwrecks/records.js", "src/layers/shipwrecks/cards.js", "src/layers/shipwrecks/eras.js", + "src/layers/shipwrecks/period.js", "src/layers/ghostMaps/catalog.js", "src/sources/httpBody.js", "src/i18n/marker.js" diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 147d9e307..7268d22ae 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3439,5 +3439,11 @@ "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ุŒ ูˆูŠุฃุชูŠ ุงู„ุญุทุงู… ุงู„ุจุงุฑุฒ ููŠ ุงู„ุนุงู„ู… ู…ู† ูˆูŠูƒูŠ ุจูŠุงู†ุงุช. ูŠุจูŠู‘ู† ุงู„ู„ูˆู† ู…ุชู‰ ุบุฑู‚ุช ุงู„ุณููŠู†ุฉุŒ ูˆูŠูƒุจุฑ ุญุฌู… ุงู„ุนู„ุงู…ุฉ ู…ุน ุนุฏุฏ ุงู„ูˆููŠุงุช. ุงู†ู‚ุฑ ุนู„ู‰ ุนู„ุงู…ุฉ ู„ุนุฑุถ ุจุทุงู‚ุชู‡ุง." + "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": "ุงู„ุณู†ุฉ ุงู„ุฃุฎูŠุฑุฉ ู…ู† ูุชุฑุฉ ุงู„ุบุฑู‚" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index cbcf280e2..29dcf5454 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3135,5 +3135,11 @@ "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." + "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6df928b0a..b6e440344 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3211,5 +3211,11 @@ "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." + "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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b9b53c48d..8aac9e0a0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3211,5 +3211,11 @@ "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." + "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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9310da82c..6d8a79983 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3211,5 +3211,11 @@ "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." + "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4a1edfb44..f1a5b6b54 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3059,5 +3059,11 @@ "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ใซใ‚ˆใ‚Šใพใ™ใ€‚่‰ฒใฏๆฒˆๆฒกใ—ใŸๆ™‚ๆœŸใ‚’็คบใ—ใ€ใƒžใƒผใ‚ซใƒผใฏๆญป่€…ๆ•ฐใซๅฟœใ˜ใฆๅคงใใใชใ‚Šใพใ™ใ€‚ใƒžใƒผใ‚ซใƒผใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ™ใ‚‹ใจใ‚ซใƒผใƒ‰ใŒ้–‹ใใพใ™ใ€‚" + "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": "้›ฃ็ ดใฎๆœŸ้–“ใฎ็ต‚ไบ†ๅนด" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 1c9b46418..73daed8c6 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3211,5 +3211,11 @@ "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." + "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1d4dbce42..bea9bea8f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3287,5 +3287,11 @@ "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. ะฆะฒะตั‚ ะฟะพะบะฐะทั‹ะฒะฐะตั‚, ะบะพะณะดะฐ ััƒะดะฝะพ ะทะฐั‚ะพะฝัƒะปะพ; ะผะฐั€ะบะตั€ ั€ะฐัั‚ั‘ั‚ ั ั‡ะธัะปะพะผ ะฟะพะณะธะฑัˆะธั…. ะะฐะถะผะธั‚ะต ะฝะฐ ะผะฐั€ะบะตั€, ั‡ั‚ะพะฑั‹ ะพั‚ะบั€ั‹ั‚ัŒ ะบะฐั€ั‚ะพั‡ะบัƒ." + "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": "ะŸะพัะปะตะดะฝะธะน ะณะพะด ะฟะตั€ะธะพะดะฐ ะบะพั€ะฐะฑะปะตะบั€ัƒัˆะตะฝะธะน" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d8b9296f0..0ee107b67 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3287,5 +3287,11 @@ "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. ะšะพะปั–ั€ ะฟะพะบะฐะทัƒั”, ะบะพะปะธ ััƒะดะฝะพ ะทะฐั‚ะพะฝัƒะปะพ; ะผะฐั€ะบะตั€ ะทะฑั–ะปัŒัˆัƒั”ั‚ัŒัั ะท ะบั–ะปัŒะบั–ัั‚ัŽ ะทะฐะณะธะฑะปะธั…. ะะฐั‚ะธัะฝั–ั‚ัŒ ะผะฐั€ะบะตั€, ั‰ะพะฑ ะฒั–ะดะบั€ะธั‚ะธ ะบะฐั€ั‚ะบัƒ." + "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": "ะžัั‚ะฐะฝะฝั–ะน ั€ั–ะบ ะฟะตั€ั–ะพะดัƒ ะบะพั€ะฐะฑะตะปัŒะฝะธั… ะฐะฒะฐั€ั–ะน" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 90684d7d5..dbdcd7c50 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3059,5 +3059,11 @@ "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ใ€‚้ขœ่‰ฒ่กจ็คบ่ˆนๅชๆฒ‰ๆฒก็š„ๆ—ถๆœŸ๏ผŒๆ ‡่ฎฐๅคงๅฐ้š้‡้šพไบบๆ•ฐๅขžๅŠ ใ€‚็‚นๅ‡ปๆ ‡่ฎฐๆŸฅ็œ‹ๅก็‰‡ใ€‚" + "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": "ๆฒ‰่ˆนๆ—ถๆฎต็š„็ป“ๆŸๅนดไปฝ" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index b1cbd78b3..4033310d7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3059,5 +3059,11 @@ "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ใ€‚้ก่‰ฒ่กจ็คบ่ˆน้šปๆฒ‰ๆฒ’็š„ๆ™‚ๆœŸ๏ผŒๆจ™่จ˜ๅคงๅฐ้šจ็ฝน้›ฃไบบๆ•ธๅขžๅŠ ใ€‚้ปžๆ“Šๆจ™่จ˜ๆŸฅ็œ‹ๅก็‰‡ใ€‚" + "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": "ๆฒ‰่ˆนๆ™‚ๆฎต็š„็ตๆŸๅนดไปฝ" } diff --git a/src/layers/shipwrecks/eras.js b/src/layers/shipwrecks/eras.js index 4e9c2a1ff..ec7b54245 100644 --- a/src/layers/shipwrecks/eras.js +++ b/src/layers/shipwrecks/eras.js @@ -19,15 +19,3 @@ export function eraOf(record) { if (year <= 1945) return '1900-1945'; return 'after-1945'; } - -/** - * Normalize a requested set of eras; unknown names are ignored and an empty - * or missing request keeps the current set. - * @param {unknown} value Array of era ids. - * @param {Set} current - * @returns {Set} - */ -export function normalizeEras(value, current) { - if (!Array.isArray(value)) return new Set(current); - return new Set(value.filter((era) => SHIPWRECK_ERAS.includes(era))); -} diff --git a/src/layers/shipwrecks/eras.test.mjs b/src/layers/shipwrecks/eras.test.mjs index 3891d60e7..41de855e8 100644 --- a/src/layers/shipwrecks/eras.test.mjs +++ b/src/layers/shipwrecks/eras.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { SHIPWRECK_ERAS, eraOf, normalizeEras } from './eras.js'; +import { eraOf } from './eras.js'; test('each wreck falls in one era', () => { assert.equal(eraOf({ year: 1588 }), 'before-1900'); @@ -11,13 +11,3 @@ test('each wreck falls in one era', () => { assert.equal(eraOf({ year: null }), 'undated'); assert.equal(eraOf(null), 'undated'); }); - -test('era requests keep known eras only', () => { - const all = new Set(SHIPWRECK_ERAS); - assert.deepEqual( - [...normalizeEras(['after-1945', 'bogus'], all)], - ['after-1945'], - ); - assert.deepEqual([...normalizeEras('after-1945', all)], SHIPWRECK_ERAS); - assert.deepEqual([...normalizeEras([], all)], []); -}); diff --git a/src/layers/shipwrecks/index.js b/src/layers/shipwrecks/index.js index 05c2c64a4..ad8f53ae9 100644 --- a/src/layers/shipwrecks/index.js +++ b/src/layers/shipwrecks/index.js @@ -2,10 +2,17 @@ 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'; +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'; @@ -28,13 +35,14 @@ export function markerPixelSize(casualties) { /** * A round marker in the era's colour with a sinking hull and mast in the - * middle, as an SVG data URL. + * 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 = - `` + + `` + `` + `` + `` + @@ -69,7 +77,9 @@ export function createShipwrecksLayer({ let _selectedId = null; let _selectedCardId = null; let _selectedLink = null; - let _eras = new Set(SHIPWRECK_ERAS); + 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])]), ); @@ -86,16 +96,16 @@ export function createShipwrecksLayer({ onChange: render, }); - /** Shown: its era chip is on. */ + /** Shown: its year is inside the period (undated ones only for all years). */ function visible(record) { - return Boolean(record) && _eras.has(eraOf(record)); + return Boolean(record) && inPeriod(record, _range); } function applyPin(pin) { pin.entity.show = pin.inFront && visible(pin.record); } - /** Re-apply the era chips to every marker and the open card. */ + /** 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(); @@ -105,6 +115,8 @@ export function createShipwrecksLayer({ function rebuild(records) { _records = new Map(records.map((record) => [record.id, record])); + _extent = yearExtent(records); + _range = periodRange(_period, _extent); _pins = []; if (!_dataSource) return; _dataSource.entities.suspendEvents(); @@ -257,18 +269,46 @@ export function createShipwrecksLayer({ 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++; + 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, as on Air accidents. */ + 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', @@ -347,17 +387,16 @@ export function createShipwrecksLayer({ _rowListener = null; }, - /** The era filter: `eras` lists the era chips that are on. */ + /** The period filter: `from` and `to` are years (null = oldest / latest in the data). */ getParams() { - return { eras: SHIPWRECK_ERAS.filter((era) => _eras.has(era)) }; + return { ..._period }; }, 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; + 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; }, @@ -368,17 +407,16 @@ export function createShipwrecksLayer({ getRowControls() { return { - chips: SHIPWRECK_ERAS.map((era) => ({ - id: era, - label: t(ERA_LABELS[era]), - active: _eras.has(era), + chips: [], + ...periodControls(), + // The colours are a legend: when the ship sank. + legend: SHIPWRECK_ERAS.map((era) => ({ + text: t(ERA_LABELS[era]), color: ERA_COLORS[era], - title: tn(countOf(era), '{count} wreck', '{count} wrecks', { + blurb: 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.', diff --git a/src/layers/shipwrecks/period.js b/src/layers/shipwrecks/period.js new file mode 100644 index 000000000..f0cfa26e2 --- /dev/null +++ b/src/layers/shipwrecks/period.js @@ -0,0 +1,71 @@ +/** + * 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. + * @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; + } + 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; +} diff --git a/src/layers/shipwrecks/period.test.mjs b/src/layers/shipwrecks/period.test.mjs new file mode 100644 index 000000000..7839a2840 --- /dev/null +++ b/src/layers/shipwrecks/period.test.mjs @@ -0,0 +1,38 @@ +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); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 4c16b047c..ea62ef39b 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -445,6 +445,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), ); @@ -474,7 +476,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 @@ -501,6 +503,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 { @@ -509,10 +512,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 || '' } @@ -577,6 +582,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( diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index 5f5d7c97e..81e2f31ed 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -1,6 +1,7 @@ @@ -68,6 +70,13 @@ defineProps({ :on-input=" (value) => onSlider(item.id, item.controls.slider.id, value) " + /> +// A row's start and end pickers on one line (e.g. the Air Accidents 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: () => {} }, +}); + + + diff --git a/src/ui/layerPanel/layerRows.test.mjs b/src/ui/layerPanel/layerRows.test.mjs index 966c41cb6..b0ccb178e 100644 --- a/src/ui/layerPanel/layerRows.test.mjs +++ b/src/ui/layerPanel/layerRows.test.mjs @@ -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: 'air-accidents', + id: 'air-accidents', + 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, [['air-accidents', 'period', 'to', '2007']]); + render(null, container); +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 91611bf4a..b69c18024 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1067,6 +1067,43 @@ html:not([lang='en']) .data-row-list-text { letter-spacing: 0.8px; } +/* Optional start and end pickers on one line (Air Accidents 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. */ From 1199fe05c7d5fda1f9ba06d8646408516674fe6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:55:16 -0300 Subject: [PATCH 07/86] fix(shipwrecks): give the era legend its labels and counts The panel reads legend entries as label and count; the layer sent text, so each entry showed 'undefined'. Co-Authored-By: Claude Opus 5.5 --- src/layers/shipwrecks/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/layers/shipwrecks/index.js b/src/layers/shipwrecks/index.js index ad8f53ae9..21fbb11ed 100644 --- a/src/layers/shipwrecks/index.js +++ b/src/layers/shipwrecks/index.js @@ -411,7 +411,8 @@ export function createShipwrecksLayer({ ...periodControls(), // The colours are a legend: when the ship sank. legend: SHIPWRECK_ERAS.map((era) => ({ - text: t(ERA_LABELS[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)), From c73e4edce7e3e9b2534245f81d955be08dfd5a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:44:17 -0300 Subject: [PATCH 08/86] docs(changelog): add #1583 fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1583-shipwrecks.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1583-shipwrecks.md diff --git a/changelog.d/1583-shipwrecks.md b/changelog.d/1583-shipwrecks.md new file mode 100644 index 000000000..06a2775c1 --- /dev/null +++ b/changelog.d/1583-shipwrecks.md @@ -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 + the same period picker as air accidents filters by year (IMBrandao, #1583). From dd83812a21daeb83b3bedbd48e99d81d53d68195 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 02:20:57 +0100 Subject: [PATCH 09/86] Hold Cockpit regional news to the last 24 hours, and tell its states apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regional News showed "REGIONAL NEWS UNAVAILABLE" above populated rows and a static "RECENT" footer over months-old articles. The status banner kept `display: grid` when hidden, every non-empty answer carried the unavailable text, and nothing limited or sorted articles by age. - One window (regionalNewsWindow.js, 24 h): the local proxy asks Google News with `when:1d` and GDELT for 24 h, drops undated, older or future-dated articles, sorts newest first, and reports newsWindowHours/newsFetchedAt. - The Cockpit labels four states: fresh (banner hidden, "LAST 24 H"), kept after a failed refresh ("CACHED ยท REFRESH FAILED"), no news in the window, and unavailable. The footer lives in the news state, so it survives page and tab changes, and the client drops out-of-window rows. - `.cockpit-brief-status[hidden]` now hides the banner. --- changelog.d/0-regional-news-freshness.md | 7 + docs/CURRENT-STATE.md | 9 +- scripts/package-boundaries.json | 16 +- server/providers/regional/briefing.js | 7 +- server/providers/regional/news.js | 43 +++- src/data/regionalBrief.test.mjs | 38 ++++ src/data/regionalModel.js | 7 + src/data/regionalNewsWindow.js | 66 ++++++ src/data/regionalProxy.test.mjs | 48 +++++ src/i18n/locales/ar.json | 7 +- src/i18n/locales/de.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/it.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/pt.json | 7 +- src/i18n/locales/ru.json | 7 +- src/i18n/locales/uk.json | 7 +- src/i18n/locales/zh-CN.json | 7 +- src/i18n/locales/zh-Hant.json | 7 +- src/ui/cockpitBriefPages.js | 26 ++- src/ui/cockpitBriefing.js | 121 +++++++++-- src/ui/cockpitBriefing.test.mjs | 249 +++++++++++++++++++++-- src/ui/styles/cockpit.css | 8 +- 24 files changed, 637 insertions(+), 85 deletions(-) create mode 100644 changelog.d/0-regional-news-freshness.md create mode 100644 src/data/regionalNewsWindow.js diff --git a/changelog.d/0-regional-news-freshness.md b/changelog.d/0-regional-news-freshness.md new file mode 100644 index 000000000..b0d6d831f --- /dev/null +++ b/changelog.d/0-regional-news-freshness.md @@ -0,0 +1,7 @@ +- Cockpit Regional News holds its list to the last 24 hours, newest first, on + the server and in the client, and labels its four states apart: fresh + results with no banner and a `LAST 24 H` footer, earlier results kept after + a failed refresh as `CACHED ยท REFRESH FAILED`, `NO NEWS IN THE LAST 24 H`, + and `REGIONAL NEWS UNAVAILABLE`. The banner is now really hidden over a + populated list, and the footer no longer reverts to a static `RECENT` on a + page change. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3dd914597..084ad4f83 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1932,8 +1932,9 @@ Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, while weather effects uses only the weather source. -GDELT compact dates that do not name a real calendar instant keep the article -with an unknown publication time. Existing process-scoped +GDELT compact dates that do not name a real calendar instant normalize to an +unknown publication time, and the 24-hour regional-news window then drops the +article. Existing process-scoped caches, rate limits, stale fallbacks and route ordering are preserved. Local voice has separate HUD-summary, debug-log and Realtime-token handlers, @@ -6096,11 +6097,11 @@ apply to `public/` assets or built-preview serving. `CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use the same gate; voice entry still opens Contacts first, as its layer-targeted entry navigates through Contacts. -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF ยท PARTIAL PICTURE` or `FLIGHTS FEED OFF ยท PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520ร—320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. +- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF ยท PARTIAL PICTURE` or `FLIGHTS FEED OFF ยท PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520ร—320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Regional News holds its list to the last 24 hours (`newsWindowHours` in the brief; the client drops anything older or undated even if a server sends it) and shows four distinct states: fresh results with no banner and a `{SOURCE} ยท LAST 24 H` footer; earlier results kept after a failed refresh under `CACHED ยท REFRESH FAILED` with the cached age in the footer; `NO NEWS IN THE LAST 24 H` when a source answered with nothing recent; and `REGIONAL NEWS UNAVAILABLE` when there is no place or no source answered, which clears the list only when there was nothing to keep. The footer follows that state across page changes. These are compact text states rather than an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. -- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM โ†’ TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000โ€“359, wrapping rounded north to 000ยฐ while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM โ†’ TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000โ€“359, wrapping rounded north to 000ยฐ while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query for the last day (`when:1d`) first, with the existing GDELT query (24 h timespan) retained only as a fail-soft fallback; both keep only articles published in the last 24 hours, newest first; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5ยฐ displays as `N ยท 0ยฐ` rather than `N ยท 360ยฐ`. - The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0โ€“359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6ยฐ track reads `COURSE 000ยฐ`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a20f7775..6ba148306 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -46,6 +46,7 @@ "src/ui/views/panels/CockpitSignalList.vue", "src/ui/views/panels/CockpitUtilityControls.vue", "src/ui/cockpitBriefPages.js", + "src/data/regionalNewsWindow.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", @@ -467,6 +468,7 @@ "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/geoDistance.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", @@ -566,6 +568,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/geoDistance.js", "src/data/naturalEarthRegions.js", "src/data/bundledJson.js", @@ -863,6 +866,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/data/regionalNewsWindow.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", @@ -992,6 +996,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/data/regionalNewsWindow.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", @@ -2785,6 +2790,7 @@ "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", + "src/data/regionalNewsWindow.js", "src/ui/cockpitContextState.js", "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", @@ -3712,6 +3718,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/sources/httpBody.js", "src/i18n/marker.js", "src/sources/nominatim.js", @@ -3955,6 +3962,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/sources/httpBody.js", "src/i18n/marker.js", "src/sources/nominatim.js", @@ -4250,7 +4258,11 @@ }, "regional-source": { "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js", "src/geoDistance.js"], + "modules": [ + "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", + "src/geoDistance.js" + ], "external": [] }, "voice-action-schemas": { @@ -4439,6 +4451,7 @@ "src/search/google.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/geoDistance.js", "src/sources/httpBody.js", "src/i18n/marker.js", @@ -4463,6 +4476,7 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/data/regionalNewsWindow.js", "src/geoDistance.js", "src/sources/httpBody.js", "src/i18n/marker.js" diff --git a/server/providers/regional/briefing.js b/server/providers/regional/briefing.js index c5b17a12b..f56c7013c 100644 --- a/server/providers/regional/briefing.js +++ b/server/providers/regional/briefing.js @@ -3,6 +3,7 @@ import { fetchRegionalPlace } from './place.js'; import { fetchRegionalWeather } from './weather.js'; import { fetchRegionalNews } from './news.js'; import { validRegionalPoint } from './query.js'; +import { REGIONAL_NEWS_WINDOW_HOURS } from '../../../src/data/regionalModel.js'; import { coalesceProxyRequest } from '../common/http.js'; // --------------------------------------------------------------------------- @@ -50,10 +51,11 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) { if (!regionalBriefHasAnySource({ place, weather, news })) { throw new Error('All regional briefing sources unavailable'); } + const retrievedAt = new Date().toISOString(); const payload = { status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial', - retrievedAt: new Date().toISOString(), + retrievedAt, coordinates: point, place, placeStatus: place ? 'ready' : 'unavailable', @@ -62,6 +64,9 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) { newsStatus: news.status, newsQuery: news.query, newsSource: news.source, + newsWindowHours: REGIONAL_NEWS_WINDOW_HOURS, + // A cached or stale answer keeps this, so the client can show its age. + newsFetchedAt: retrievedAt, articles: news.articles, }; _regionalBriefCache.set(key, { payload, cachedAt: Date.now() }); diff --git a/server/providers/regional/news.js b/server/providers/regional/news.js index 155dfa908..085a6ba72 100644 --- a/server/providers/regional/news.js +++ b/server/providers/regional/news.js @@ -1,5 +1,10 @@ import { fetchRegionalText, fetchRegionalJson } from './http.js'; -import { normalizeRegionalArticles } from '../../../src/data/regionalModel.js'; +import { + REGIONAL_NEWS_WINDOW_HOURS, + googleNewsWindowOperator, + normalizeRegionalArticles, + recentRegionalArticles, +} from '../../../src/data/regionalModel.js'; function decodeRssText(value) { return String(value || '') @@ -22,7 +27,10 @@ function rssTag(block, tag) { ); } -function normalizeRssArticles(xml, limit = 5) { +// Feed items read before the window filter; Google returns about 100. +const RSS_ITEM_CAP = 100; + +function normalizeRssArticles(xml, limit = RSS_ITEM_CAP) { const seen = new Set(); const articles = []; for (const match of String(xml || '').matchAll( @@ -57,12 +65,27 @@ function normalizeRssArticles(xml, limit = 5) { return articles; } -async function fetchRegionalNews(place) { +/** + * Headlines for a place from the last REGIONAL_NEWS_WINDOW_HOURS, newest + * first: Google News RSS, then GDELT when RSS fails or has nothing recent. + * `empty` means a source answered with nothing in the window; `unavailable` + * means there was no place or no source answered. + * @param {{locality?: string, region?: string, country?: string}|null} place + * @param {{now?: () => number}} [options] + */ +async function fetchRegionalNews(place, { now = Date.now } = {}) { const query = place?.locality || place?.region || place?.country; if (!query) return { status: 'unavailable', query: null, articles: [], source: null }; + const recent = (articles) => + recentRegionalArticles(articles, { + now: now(), + windowHours: REGIONAL_NEWS_WINDOW_HOURS, + limit: 5, + }); + let answered = false; const rssParams = new URLSearchParams({ - q: String(query).replace(/["\\]/g, ' ').trim(), + q: `${String(query).replace(/["\\]/g, ' ').trim()} ${googleNewsWindowOperator()}`, hl: 'en-US', gl: 'US', ceid: 'US:en', @@ -75,7 +98,8 @@ async function fetchRegionalNews(place) { timeoutMs: 12_000, }, ); - const articles = normalizeRssArticles(xml, 5); + answered = true; + const articles = recent(normalizeRssArticles(xml)); if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' }; } catch { @@ -87,7 +111,7 @@ async function fetchRegionalNews(place) { format: 'json', maxrecords: '5', sort: 'datedesc', - timespan: '48h', + timespan: `${REGIONAL_NEWS_WINDOW_HOURS}h`, }); try { const payload = await fetchRegionalJson( @@ -97,7 +121,7 @@ async function fetchRegionalNews(place) { timeoutMs: 12_000, }, ); - const articles = normalizeRegionalArticles(payload, 5); + const articles = recent(normalizeRegionalArticles(payload, 5)); return { status: articles.length ? 'ready' : 'empty', query, @@ -105,7 +129,10 @@ async function fetchRegionalNews(place) { source: 'GDELT fallback', }; } catch { - return { status: 'unavailable', query, articles: [], source: null }; + // An RSS answer with nothing recent is still a true empty result. + return answered + ? { status: 'empty', query, articles: [], source: 'Google News RSS' } + : { status: 'unavailable', query, articles: [], source: null }; } } diff --git a/src/data/regionalBrief.test.mjs b/src/data/regionalBrief.test.mjs index 18094ec1c..4606b6ad1 100644 --- a/src/data/regionalBrief.test.mjs +++ b/src/data/regionalBrief.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + REGIONAL_NEWS_WINDOW_HOURS, + googleNewsWindowOperator, normalizeRegionalArticles, + recentRegionalArticles, + regionalNewsWindowHours, normalizeRegionalPlace, normalizeRegionalWeather, regionalDistanceM, @@ -61,3 +65,37 @@ test('regional distance handles nearby movement and missing positions', () => { assert.ok(distance > 11000 && distance < 11200); assert.equal(regionalDistanceM(null, null), Infinity); }); + +test('regional news is held to one 24-hour window, newest first, undated dropped', () => { + assert.equal(REGIONAL_NEWS_WINDOW_HOURS, 24); + assert.equal(googleNewsWindowOperator(), 'when:1d'); + assert.equal(googleNewsWindowOperator(6), 'when:6h'); + assert.equal(regionalNewsWindowHours('12'), 12); + assert.equal(regionalNewsWindowHours(0), 24); + assert.equal(regionalNewsWindowHours(undefined), 24); + const now = Date.parse('2026-11-04T12:00:00Z'); + const at = (hours) => new Date(now - hours * 3_600_000).toISOString(); + const rows = [ + { title: 'a', publishedAt: at(23) }, + { title: 'stale', publishedAt: at(25) }, + { title: 'undated', publishedAt: null }, + { title: 'garbled', publishedAt: 'not a date' }, + { title: 'b', publishedAt: at(1) }, + { title: 'future', publishedAt: at(-2) }, + { title: 'c', publishedAt: at(5) }, + ]; + assert.deepEqual( + recentRegionalArticles(rows, { now }).map((row) => row.title), + ['b', 'c', 'a'], + ); + assert.deepEqual( + recentRegionalArticles(rows, { now, limit: 2 }).map((row) => row.title), + ['b', 'c'], + ); + assert.deepEqual( + recentRegionalArticles(rows, { now, windowHours: 4 }).map((row) => row.title), + ['b'], + ); + assert.deepEqual(recentRegionalArticles([], { now }), []); + assert.deepEqual(recentRegionalArticles(undefined, { now }), []); +}); diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 46f162d6f..281fc873f 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -55,6 +55,13 @@ export function normalizeRegionalPlace(payload) { }; } +export { + REGIONAL_NEWS_WINDOW_HOURS, + googleNewsWindowOperator, + recentRegionalArticles, + regionalNewsWindowHours, +} from './regionalNewsWindow.js'; + /** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { const rows = Array.isArray(payload?.articles) ? payload.articles : []; diff --git a/src/data/regionalNewsWindow.js b/src/data/regionalNewsWindow.js new file mode 100644 index 000000000..15180eee0 --- /dev/null +++ b/src/data/regionalNewsWindow.js @@ -0,0 +1,66 @@ +/** The regional-news freshness window, shared by the servers and the Cockpit. */ + +/** At most this many articles answer a regional brief. */ +export const REGIONAL_NEWS_ROWS = 5; + +/** + * The regional-news freshness window, in hours. The servers ask upstream for + * this window, drop anything older and report it as `newsWindowHours`; the + * Cockpit labels its list with it. + */ +export const REGIONAL_NEWS_WINDOW_HOURS = 24; + +// A publish time this far ahead of the clock is skew, not a future story. +const REGIONAL_NEWS_CLOCK_SKEW_MS = 15 * 60_000; + +/** + * A served window in hours, or the default when it is missing or out of range. + * @param {unknown} value + * @returns {number} + */ +export function regionalNewsWindowHours(value) { + const hours = Number(value); + return Number.isInteger(hours) && hours >= 1 && hours <= 168 + ? hours + : REGIONAL_NEWS_WINDOW_HOURS; +} + +/** + * The Google News search operator asking for the window: `when:1d` for 24 h. + * @param {number} [hours] + * @returns {string} + */ +export function googleNewsWindowOperator(hours = REGIONAL_NEWS_WINDOW_HOURS) { + const span = regionalNewsWindowHours(hours); + return span % 24 === 0 ? `when:${span / 24}d` : `when:${span}h`; +} + +/** + * The articles published inside the window, newest first, at most `limit`. + * An article without a parseable publish time is dropped: nothing shows it is + * recent. One dated more than 15 minutes ahead of `now` is dropped too. + * @param {Array<{publishedAt?: string|null}>} articles + * @param {{now?: number, windowHours?: number, limit?: number}} [options] + * @returns {Array} + */ +export function recentRegionalArticles( + articles, + { + now = Date.now(), + windowHours = REGIONAL_NEWS_WINDOW_HOURS, + limit = REGIONAL_NEWS_ROWS, + } = {}, +) { + const oldest = now - regionalNewsWindowHours(windowHours) * 3_600_000; + return (Array.isArray(articles) ? articles : []) + .map((article) => ({ article, at: Date.parse(article?.publishedAt) })) + .filter( + ({ at }) => + Number.isFinite(at) && + at >= oldest && + at <= now + REGIONAL_NEWS_CLOCK_SKEW_MS, + ) + .sort((a, b) => b.at - a.at) + .slice(0, Math.max(0, limit)) + .map(({ article }) => article); +} diff --git a/src/data/regionalProxy.test.mjs b/src/data/regionalProxy.test.mjs index 97891bef8..b1e56da36 100644 --- a/src/data/regionalProxy.test.mjs +++ b/src/data/regionalProxy.test.mjs @@ -11,6 +11,7 @@ import createViteConfig, { validMilitaryInstallationBox, validRegionalPoint, } from '../../vite.config.js'; +import { fetchRegionalNews } from '../../server/providers/regional/news.js'; test('missing Google place context is a quiet keyless capability, not a 503', () => { assert.deepEqual(keylessGooglePlacesResponse(undefined), { @@ -113,3 +114,50 @@ test('regional brief treats an all-source outage as total failure', () => { news: { status: 'unavailable' }, }), true); }); + +test('local regional news asks for the last day, drops old and undated items, newest first', async (t) => { + const realFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = realFetch; }); + const urls = []; + let rss; + let gdelt; + globalThis.fetch = async (url) => { + urls.push(new URL(String(url))); + const answer = String(url).includes('news.google') ? rss : gdelt; + return typeof answer === 'function' ? answer() : answer.clone(); + }; + const at = (hours) => new Date(Date.now() - hours * 3_600_000).toUTCString(); + const item = (title, date) => + `${title}https://news.example/${title}${date ? `${date}` : ''}`; + rss = new Response(`${[ + item('old', at(4 * 24)), + item('undated', null), + item('day', at(20)), + item('hour', at(1)), + ].join('')}`); + const place = { locality: 'Austin' }; + const ready = await fetchRegionalNews(place); + assert.equal(urls[0].searchParams.get('q'), 'Austin when:1d'); + assert.equal(ready.status, 'ready'); + assert.deepEqual(ready.articles.map((row) => row.title), ['hour', 'day']); + + // Nothing recent in RSS: GDELT is asked for the same 24 h window. + rss = new Response(`${item('old', at(30))}`); + gdelt = Response.json({ articles: [] }); + urls.length = 0; + const empty = await fetchRegionalNews(place); + assert.equal(urls[1].searchParams.get('timespan'), '24h'); + assert.deepEqual(empty, { status: 'empty', query: 'Austin', articles: [], source: 'GDELT fallback' }); + + // RSS answered with nothing recent and GDELT failed: still a true empty. + rss = new Response(''); + gdelt = () => Promise.reject(new TypeError('network')); + assert.equal((await fetchRegionalNews(place)).status, 'empty'); + + // Neither source answered, or no place: unavailable. + rss = () => Promise.resolve(new Response('busy', { status: 503 })); + const failed = await fetchRegionalNews(place); + assert.equal(failed.status, 'unavailable'); + assert.deepEqual(failed.articles, []); + assert.equal((await fetchRegionalNews(null)).status, 'unavailable'); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f32216a1e..186bd4e1e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -388,11 +388,12 @@ "PLACE / CONDITIONS / POSITION": "ุงู„ู…ูƒุงู† / ุงู„ุฃุญูˆุงู„ / ุงู„ู…ูˆุถุน", "POSITION UNAVAILABLE": "ุงู„ู…ูˆุถุน ุบูŠุฑ ู…ุชุงุญ", "REGIONAL NEWS UNAVAILABLE": "ุงู„ุฃุฎุจุงุฑ ุงู„ุฅู‚ู„ูŠู…ูŠุฉ ุบูŠุฑ ู…ุชุงุญุฉ", + "CACHED ยท REFRESH FAILED": "ู…ุฎุฒู†ุฉ ยท ูุดู„ ุงู„ุชุญุฏูŠุซ", "REGION UNAVAILABLE": "ุงู„ู…ู†ุทู‚ุฉ ุบูŠุฑ ู…ุชุงุญุฉ", - "NO RECENT LOCATION MATCHES": "ู„ุง ุชูˆุฌุฏ ุชู‚ุงุฑูŠุฑ ุญุฏูŠุซุฉ ู…ุทุงุจู‚ุฉ ู„ู„ู…ูˆู‚ุน", + "NO NEWS IN THE LAST {hours} H": "ู„ุง ุฃุฎุจุงุฑ ุฎู„ุงู„ ุขุฎุฑ {hours} ุณ", "SOURCE": "ุงู„ู…ุตุฏุฑ", "{domain} ยท {age}": "{domain} ยท {age}", - "{source} ยท LOCATION QUERY": "{source} ยท ุงุณุชุนู„ุงู… ุงู„ู…ูˆู‚ุน", + "{source} ยท CACHED {age}": "{source} ยท ู…ุฎุฒู†ุฉ {age}", "TIME UNKNOWN": "ุงู„ูˆู‚ุช ุบูŠุฑ ู…ุนุฑูˆู", "{count}M AGO": "ู‚ุจู„ {count} ุฏ", "{count}H AGO": "ู‚ุจู„ {count} ุณ", @@ -2194,7 +2195,7 @@ "ADS-B PRIORITY ยท {status}": "ุฃูˆู„ูˆูŠุฉ ADS-B ยท {status}", "ADS-B EMERGENCY ยท {status}": "ุทูˆุงุฑุฆ ADS-B ยท {status}", "{aircraftClass} ยท {emergency} ยท {feedState}": "{aircraftClass} ยท {emergency} ยท {feedState}", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ุงุณุชุนู„ุงู… ุงู„ู…ูˆู‚ุน ยท ุญุฏูŠุซ", + "{source} ยท LAST {hours} H": "{source} ยท ุขุฎุฑ {hours} ุณ", "Rendered globe frames per second ยท toggle with `": "ุฅุทุงุฑุงุช ุงู„ูƒุฑุฉ ุงู„ุฃุฑุถูŠุฉ ุงู„ู…ุนุฑูˆุถุฉ ููŠ ุงู„ุซุงู†ูŠุฉ ยท ุจุฏู‘ู„ ุจุงู„ู…ูุชุงุญ `", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "ุชุนุฐู‘ุฑ ุชุญุฏูŠุซ ุงุฑุชูุงุนุงุช ุงู„ุฃุฑุถ. ุฃุนุฏ ุงุณุชูŠุฑุงุฏ ุงู„ู…ู„ู ุจุนุฏ ุชุญู…ูŠู„ ุงู„ุฎุฑูŠุทุฉ.", "{count} map objects": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b9d89d2fb..0fa269e20 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -399,13 +399,14 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Briefing-Seiten automatisch alle 9 Sekunden wechseln (Signale โ†’ News โ†’ Lokal). Pausiert, solange sich der Mauszeiger รผber dem Bereich befindet oder dieser den Fokus hat. Live-Signaldaten werden in beiden Fรคllen laufend aktualisiert.", "ACQUIRING REGIONAL NEWS": "REGIONALE NEWS WERDEN ERFASST", "REGIONAL NEWS UNAVAILABLE": "REGIONALE NEWS NICHT VERFรœGBAR", + "CACHED ยท REFRESH FAILED": "CACHE ยท AKTUALISIERUNG FEHLGESCHLAGEN", "RESOLVING REGION": "REGION WIRD ERMITTELT", "REGION UNAVAILABLE": "REGION NICHT VERFรœGBAR", - "NO RECENT LOCATION MATCHES": "KEINE AKTUELLEN ORTSTREFFER", + "NO NEWS IN THE LAST {hours} H": "KEINE NEWS IN DEN LETZTEN {hours} H", "{domain} ยท {age}": "{domain} ยท {age}", "CLOUD {pct}%": "WOLKEN {pct} %", "CLOUD UNKNOWN": "WOLKEN UNBEKANNT", - "{source} ยท LOCATION QUERY": "{source} ยท ORTSABFRAGE", + "{source} ยท CACHED {age}": "{source} ยท CACHE {age}", "REGIONAL NEWS": "REGIONALE NEWS", "CONTEXT STANDBY": "KONTEXT STANDBY", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "GLOBALEN KONTEXT FรœR Nร„HE-PINGS AKTIVIEREN", @@ -2094,7 +2095,7 @@ "ADS-B PRIORITY ยท {status}": "ADS-B-PRIORITร„T ยท {status}", "ADS-B EMERGENCY ยท {status}": "ADS-B-NOTFALL ยท {status}", "{aircraftClass} ยท {emergency} ยท {feedState}": "{aircraftClass} ยท {emergency} ยท {feedState}", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ORTSABFRAGE ยท AKTUELL", + "{source} ยท LAST {hours} H": "{source} ยท LETZTE {hours} H", "Rendered globe frames per second ยท toggle with `": "Gerenderte Globus-Frames pro Sekunde ยท umschalten mit `", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Gelรคndehรถhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.", "{count} map objects": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6fefd6d91..078096514 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -274,11 +274,12 @@ "PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIร“N", "POSITION UNAVAILABLE": "POSICIร“N NO DISPONIBLE", "REGIONAL NEWS UNAVAILABLE": "NOTICIAS DE ZONA NO DISPONIBLES", + "CACHED ยท REFRESH FAILED": "EN CACHร‰ ยท ERROR AL ACTUALIZAR", "REGION UNAVAILABLE": "REGIร“N NO DISPONIBLE", - "NO RECENT LOCATION MATCHES": "SIN COINCIDENCIAS RECIENTES DE UBICACIร“N", + "NO NEWS IN THE LAST {hours} H": "SIN NOTICIAS EN LAS รšLTIMAS {hours} H", "SOURCE": "FUENTE", "{domain} ยท {age}": "{domain} ยท {age}", - "{source} ยท LOCATION QUERY": "{source} ยท CONSULTA POR UBICACIร“N", + "{source} ยท CACHED {age}": "{source} ยท EN CACHร‰ {age}", "TIME UNKNOWN": "HORA DESCONOCIDA", "{count}M AGO": "HACE {count} MIN", "{count}H AGO": "HACE {count} H", @@ -1923,7 +1924,7 @@ "MILITARY FEED OFF ยท PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA ยท PANORAMA PARCIAL", "FLIGHTS FEED OFF ยท PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA ยท PANORAMA PARCIAL", "NEARBY AIRCRAFT INCOMPLETE ยท NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS ยท NO IMPLICA TODO DESPEJADO", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท CONSULTA POR UBICACIร“N ยท RECIENTE", + "{source} ยท LAST {hours} H": "{source} ยท รšLTIMAS {hours} H", "ft": "ft", "kts": "kn", "PAUSE": "PAUSA", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 85957a6c8..3ce27c53c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1649,14 +1649,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire dรฉfiler automatiquement les pages du briefing toutes les 9 secondes (Signaux โ†’ Actus โ†’ Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.", "ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS Rร‰GIONALES", "REGIONAL NEWS UNAVAILABLE": "ACTUS Rร‰GIONALES INDISPONIBLES", + "CACHED ยท REFRESH FAILED": "EN CACHE ยท ร‰CHEC DE Lโ€™ACTUALISATION", "RESOLVING REGION": "IDENTIFICATION DE LA Rร‰GION", "REGION UNAVAILABLE": "Rร‰GION INDISPONIBLE", - "NO RECENT LOCATION MATCHES": "AUCUNE ACTU Rร‰CENTE POUR CE LIEU", + "NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIรˆRES H", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} KM/H", "CLOUD {pct}%": "Nร‰BULOSITร‰ {pct}โ€ฏ%", "CLOUD UNKNOWN": "Nร‰BULOSITร‰ INCONNUE", - "{source} ยท LOCATION QUERY": "{source} ยท REQUรŠTE DE LIEU", + "{source} ยท CACHED {age}": "{source} ยท EN CACHE {age}", "REGIONAL NEWS": "ACTUS Rร‰GIONALES", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท AIR/MER {radius}", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท INSTALLATIONSโ€ฏ: {coverage}", @@ -1716,7 +1717,7 @@ "OBSERVED / MAPPED PINGS": "PINGS OBSERVร‰S / CARTOGRAPHIร‰S", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "ร‰Vร‰NEMENTS SOURCร‰S ยท AUCUNE ACTU SYNTHร‰TIQUE", "LATEST LOCATION-MATCHED REPORTING": "DERNIรˆRES ACTUS LIร‰ES AU LIEU", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท REQUรŠTE DE LIEU ยท Rร‰CENT", + "{source} ยท LAST {hours} H": "{source} ยท {hours} DERNIรˆRES H", "LOCAL INFO": "INFOS LOCALES", "PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION", "TIME UNKNOWN": "HEURE INCONNUE", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index add839aac..36f25a0cf 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1649,14 +1649,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Scorri automaticamente le pagine del briefing ogni 9 secondi (Segnali โ†’ Notizie โ†’ Locale). Lo scorrimento si interrompe quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.", "ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI", "REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI", + "CACHED ยท REFRESH FAILED": "IN CACHE ยท AGGIORNAMENTO NON RIUSCITO", "RESOLVING REGION": "IDENTIFICAZIONE REGIONE", "REGION UNAVAILABLE": "REGIONE NON DISPONIBILE", - "NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE", + "NO NEWS IN THE LAST {hours} H": "NESSUNA NOTIZIA NELLE ULTIME {hours} H", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} KM/H", "CLOUD {pct}%": "NUVOLE {pct}%", "CLOUD UNKNOWN": "NUVOLE N/D", - "{source} ยท LOCATION QUERY": "{source} ยท QUERY PER POSIZIONE", + "{source} ยท CACHED {age}": "{source} ยท IN CACHE {age}", "REGIONAL NEWS": "NOTIZIE REGIONALI", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท ARIA/MARE {radius}", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท SITI {coverage}", @@ -1716,7 +1717,7 @@ "OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "EVENTI DA FONTI ยท NESSUNA NOTIZIA SINTETICA", "LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท QUERY PER POSIZIONE ยท RECENTI", + "{source} ยท LAST {hours} H": "{source} ยท ULTIME {hours} H", "LOCAL INFO": "INFO LOCALI", "PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE", "TIME UNKNOWN": "ORA SCONOSCIUTA", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index dde397f27..e586716f8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1601,14 +1601,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "9็ง’ใ”ใจใซใƒ–ใƒชใƒผใƒ•ใ‚ฃใƒณใ‚ฐใƒšใƒผใ‚ธใ‚’่‡ชๅ‹•ใงๅˆ‡ใ‚Šๆ›ฟใˆใพใ™๏ผˆใ‚ทใ‚ฐใƒŠใƒซ โ†’ ใƒ‹ใƒฅใƒผใ‚น โ†’ ใƒญใƒผใ‚ซใƒซ๏ผ‰ใ€‚ใƒ‘ใƒใƒซใซใ‚ซใƒผใ‚ฝใƒซใ‚’ๅˆใ‚ใ›ใ‚‹ใ‹ใƒ•ใ‚ฉใƒผใ‚ซใ‚นใ—ใฆใ„ใ‚‹้–“ใฏไธ€ๆ™‚ๅœๆญขใ—ใพใ™ใ€‚ใƒฉใ‚คใƒ–ใ‚ทใ‚ฐใƒŠใƒซใฎใƒ‡ใƒผใ‚ฟใฏใ€ใฉใกใ‚‰ใฎๅ ดๅˆใ‚‚็ถ™็ถš็š„ใซๆ›ดๆ–ฐใ•ใ‚Œใพใ™ใ€‚", "ACQUIRING REGIONAL NEWS": "ๅœฐๅŸŸใƒ‹ใƒฅใƒผใ‚นใ‚’ๅ–ๅพ—ไธญ", "REGIONAL NEWS UNAVAILABLE": "ๅœฐๅŸŸใƒ‹ใƒฅใƒผใ‚นใ‚’ๅ–ๅพ—ใงใใพใ›ใ‚“", + "CACHED ยท REFRESH FAILED": "ใ‚ญใƒฃใƒƒใ‚ทใƒฅ ยท ๆ›ดๆ–ฐๅคฑๆ•—", "RESOLVING REGION": "ๅœฐๅŸŸใ‚’็‰นๅฎšไธญ", "REGION UNAVAILABLE": "ๅœฐๅŸŸใ‚’ๅ–ๅพ—ใงใใพใ›ใ‚“", - "NO RECENT LOCATION MATCHES": "ใ“ใฎๅœฐๅŸŸใฎๆœ€่ฟ‘ใฎใƒ‹ใƒฅใƒผใ‚นใชใ—", + "NO NEWS IN THE LAST {hours} H": "้ŽๅŽป{hours}ๆ™‚้–“ใฎใƒ‹ใƒฅใƒผใ‚นใชใ—", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "้›ฒ้‡ {pct}%", "CLOUD UNKNOWN": "้›ฒ้‡ไธๆ˜Ž", - "{source} ยท LOCATION QUERY": "{source} ยท ไฝ็ฝฎใ‚ฏใ‚จใƒช", + "{source} ยท CACHED {age}": "{source} ยท ใ‚ญใƒฃใƒƒใ‚ทใƒฅ {age}", "REGIONAL NEWS": "ๅœฐๅŸŸใƒ‹ใƒฅใƒผใ‚น", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท ่ˆช็ฉบๆฉŸ/่ˆน่ˆถใฎ็ฏ„ๅ›ฒ {radius}", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท ๆ–ฝ่จญ {coverage}", @@ -1666,7 +1667,7 @@ "OBSERVED / MAPPED PINGS": "่ฆณๆธฌ / ใƒžใƒƒใƒ—ๆธˆใฟใƒ”ใƒณใ‚ฐ", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "ๅ‡บๅ…ธไป˜ใใ‚คใƒ™ใƒณใƒˆใฎใฟ ยท ๅˆๆˆใƒ‹ใƒฅใƒผใ‚นใชใ—", "LATEST LOCATION-MATCHED REPORTING": "ไฝ็ฝฎใซไธ€่‡ดใ™ใ‚‹ๆœ€ๆ–ฐใฎๅ ฑ้“", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ไฝ็ฝฎใ‚ฏใ‚จใƒช ยท ๆœ€่ฟ‘", + "{source} ยท LAST {hours} H": "{source} ยท ้ŽๅŽป{hours}ๆ™‚้–“", "LOCAL INFO": "ใƒญใƒผใ‚ซใƒซๆƒ…ๅ ฑ", "PLACE / CONDITIONS / POSITION": "ๅ ดๆ‰€ / ๅคฉๅ€™ / ไฝ็ฝฎ", "TIME UNKNOWN": "ๆ™‚ๅˆปไธๆ˜Ž", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5b4b793eb..13d3fbc85 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1649,14 +1649,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Alternar as pรกginas do briefing automaticamente a cada 9 segundos (Sinais โ†’ Notรญcias โ†’ Local). A alternรขncia pausa enquanto vocรช passa o mouse ou foca o painel. Os dados de sinais ao vivo continuam sendo atualizados de qualquer forma.", "ACQUIRING REGIONAL NEWS": "OBTENDO NOTรCIAS DA รREA", "REGIONAL NEWS UNAVAILABLE": "NOTรCIAS DA รREA INDISPONรVEIS", + "CACHED ยท REFRESH FAILED": "EM CACHE ยท FALHA AO ATUALIZAR", "RESOLVING REGION": "IDENTIFICANDO REGIรƒO", "REGION UNAVAILABLE": "REGIรƒO INDISPONรVEL", - "NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDรŠNCIA RECENTE COM O LOCAL", + "NO NEWS IN THE LAST {hours} H": "SEM NOTรCIAS NAS รšLTIMAS {hours} H", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} KM/H", "CLOUD {pct}%": "NUVENS {pct}%", "CLOUD UNKNOWN": "NEBULOSIDADE DESCONHECIDA", - "{source} ยท LOCATION QUERY": "{source} ยท CONSULTA POR LOCAL", + "{source} ยท CACHED {age}": "{source} ยท EM CACHE {age}", "REGIONAL NEWS": "NOTรCIAS DA รREA", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท AR/MAR {radius}", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท INSTALAร‡ร•ES: {coverage}", @@ -1716,7 +1717,7 @@ "OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "EVENTOS COM FONTE ยท SEM NOTรCIAS SINTร‰TICAS", "LATEST LOCATION-MATCHED REPORTING": "รšLTIMAS NOTรCIAS DO LOCAL", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท CONSULTA POR LOCAL ยท RECENTE", + "{source} ยท LAST {hours} H": "{source} ยท รšLTIMAS {hours} H", "LOCAL INFO": "INFO LOCAL", "PLACE / CONDITIONS / POSITION": "LUGAR / CONDIร‡ร•ES / POSIร‡รƒO", "TIME UNKNOWN": "HORA DESCONHECIDA", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1ffa64ac1..766af1005 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -366,10 +366,11 @@ "OVERCAST": "ะŸะะกะœะฃะ ะะž", "ACQUIRING REGIONAL NEWS": "ะ—ะะ“ะ ะฃะ—ะšะ ะ ะ•ะ“ะ˜ะžะะะ›ะฌะะซะฅ ะะžะ’ะžะกะขะ•ะ™", "REGIONAL NEWS UNAVAILABLE": "ะ ะ•ะ“ะ˜ะžะะะ›ะฌะะซะ• ะะžะ’ะžะกะขะ˜ ะะ•ะ”ะžะกะขะฃะŸะะซ", + "CACHED ยท REFRESH FAILED": "ะšะญะจ ยท ะžะ‘ะะžะ’ะ›ะ•ะะ˜ะ• ะะ• ะฃะ”ะะ›ะžะกะฌ", "RESOLVING REGION": "ะžะŸะ ะ•ะ”ะ•ะ›ะ•ะะ˜ะ• ะ ะ•ะ“ะ˜ะžะะ", "REGION UNAVAILABLE": "ะ ะ•ะ“ะ˜ะžะ ะะ•ะ”ะžะกะขะฃะŸะ•ะ", "FOG": "ะขะฃะœะะ", - "NO RECENT LOCATION MATCHES": "ะะ•ะข ะกะ’ะ•ะ–ะ˜ะฅ ะกะžะ’ะŸะะ”ะ•ะะ˜ะ™ ะŸะž ะœะ•ะกะขะฃ", + "NO NEWS IN THE LAST {hours} H": "ะะ•ะข ะะžะ’ะžะกะขะ•ะ™ ะ—ะ {hours} ะง", "DRIZZLE": "ะœะžะ ะžะกะฌ", "RAIN": "ะ”ะžะ–ะ”ะฌ", "{domain} ยท {age}": "{domain} ยท {age}", @@ -381,7 +382,7 @@ "CLOUD {pct}%": "ะžะ‘ะ›ะะงะะžะกะขะฌ {pct}%", "CLOUD UNKNOWN": "ะžะ‘ะ›ะะงะะžะกะขะฌ ะะ•ะ˜ะ—ะ’ะ•ะกะขะะ", "MIXED CONDITIONS": "ะกะœะ•ะจะะะะซะ• ะฃะกะ›ะžะ’ะ˜ะฏ", - "{source} ยท LOCATION QUERY": "{source} ยท ะ—ะะŸะ ะžะก ะŸะž ะœะ•ะกะขะฃ", + "{source} ยท CACHED {age}": "{source} ยท ะšะญะจ {age}", "REGIONAL NEWS": "ะะžะ’ะžะกะขะ˜ ะ ะ•ะ“ะ˜ะžะะ", "CYCLE ON": "ะะ’ะขะž: ะ’ะšะ›", "CYCLE OFF": "ะะ’ะขะž: ะ’ะซะšะ›", @@ -1956,7 +1957,7 @@ "MILITARY FEED OFF ยท PARTIAL PICTURE": "ะ’ะžะ•ะะะซะ™ ะŸะžะขะžะš ะ’ะซะšะ› ยท ะšะะ ะขะ˜ะะ ะะ•ะŸะžะ›ะะะฏ", "FLIGHTS FEED OFF ยท PARTIAL PICTURE": "ะŸะžะขะžะš ะ ะ•ะ™ะกะžะ’ ะ’ะซะšะ› ยท ะšะะ ะขะ˜ะะ ะะ•ะŸะžะ›ะะะฏ", "NEARBY AIRCRAFT INCOMPLETE ยท NOT AN ALL-CLEAR": "ะ‘ะ›ะ˜ะ–ะะ˜ะ• ะ’ะžะ—ะ”ะฃะจะะซะ• ะกะฃะ”ะ: ะ”ะะะะซะ• ะะ•ะŸะžะ›ะะซะ• ยท ะžะขะกะฃะขะกะขะ’ะ˜ะ• ะฃะ“ะ ะžะ— ะะ• ะŸะžะ”ะขะ’ะ•ะ ะ–ะ”ะ•ะะž", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ะ—ะะŸะ ะžะก ะŸะž ะœะ•ะกะขะฃ ยท ะะ•ะ”ะะ’ะะ˜ะ•", + "{source} ยท LAST {hours} H": "{source} ยท ะ—ะ {hours} ะง", "ft": "ั„ั‚", "kts": "ัƒะท", "PAUSE": "ะŸะะฃะ—ะ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 5c93f74b7..388ea566d 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1672,14 +1672,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "ะะฒั‚ะพะผะฐั‚ะธั‡ะฝะพ ะณะพั€ั‚ะฐั” ัั‚ะพั€ั–ะฝะบะธ ะฑั€ะธั„ั–ะฝะณัƒ ะบะพะถะฝั– 9 ัะตะบัƒะฝะด (ะกะธะณะฝะฐะปะธ โ†’ ะะพะฒะธะฝะธ โ†’ ะœั–ัั†ะตะฒั– ะดะฐะฝั–). ะŸะฐัƒะทะฐ, ะดะพะบะธ ะฒะธ ะฝะฐะฒะพะดะธั‚ะต ะฒะบะฐะทั–ะฒะฝะธะบ ะฝะฐ ะฟะฐะฝะตะปัŒ ะฐะฑะพ ะฒะพะฝะฐ ัƒ ั„ะพะบัƒัั–. ะ”ะฐะฝั– ัะธะณะฝะฐะปั–ะฒ ัƒ ั€ะตะฐะปัŒะฝะพะผัƒ ั‡ะฐัั– ะพะดะฝะฐะบะพะฒะพ ะพะฝะพะฒะปัŽัŽั‚ัŒัั ะฑะตะทะฟะตั€ะตั€ะฒะฝะพ.", "ACQUIRING REGIONAL NEWS": "ะžะขะ ะ˜ะœะะะะฏ ะ ะ•ะ“ะ†ะžะะะ›ะฌะะ˜ะฅ ะะžะ’ะ˜ะ", "REGIONAL NEWS UNAVAILABLE": "ะ ะ•ะ“ะ†ะžะะะ›ะฌะะ† ะะžะ’ะ˜ะะ˜ ะะ•ะ”ะžะกะขะฃะŸะะ†", + "CACHED ยท REFRESH FAILED": "ะšะ•ะจ ยท ะžะะžะ’ะ›ะ•ะะะฏ ะะ• ะ’ะ”ะะ›ะžะกะฏ", "RESOLVING REGION": "ะ’ะ˜ะ—ะะะงะ•ะะะฏ ะ ะ•ะ“ะ†ะžะะฃ", "REGION UNAVAILABLE": "ะ ะ•ะ“ะ†ะžะ ะะ•ะ”ะžะกะขะฃะŸะะ˜ะ™", - "NO RECENT LOCATION MATCHES": "ะะ•ะœะะ„ ะกะ’ะ†ะ–ะ˜ะฅ ะ—ะ‘ะ†ะ“ะ†ะ’ ะ—ะ ะ ะžะ—ะขะะจะฃะ’ะะะะฏะœ", + "NO NEWS IN THE LAST {hours} H": "ะะ•ะœะะ„ ะะžะ’ะ˜ะ ะ—ะ {hours} ะ“ะžะ”", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} ะšะœ/ะ“ะžะ”", "CLOUD {pct}%": "ะฅะœะะ ะะ†ะกะขะฌ {pct}%", "CLOUD UNKNOWN": "ะฅะœะะ ะะ†ะกะขะฌ ะะ•ะ’ะ†ะ”ะžะœะ", - "{source} ยท LOCATION QUERY": "{source} ยท ะ—ะะŸะ˜ะข ะ—ะ ะ ะžะ—ะขะะจะฃะ’ะะะะฏะœ", + "{source} ยท CACHED {age}": "{source} ยท ะšะ•ะจ {age}", "REGIONAL NEWS": "ะ ะ•ะ“ะ†ะžะะะ›ะฌะะ† ะะžะ’ะ˜ะะ˜", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท ะ ะ•ะ™ะกะ˜ / ะกะฃะ”ะะ ยท ะ ะะ”ะ†ะฃะก {radius}", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท ะžะ‘สผะ„ะšะขะ˜: {coverage}", @@ -1740,7 +1741,7 @@ "OBSERVED / MAPPED PINGS": "ะกะŸะžะกะขะ•ะ ะ•ะ–ะ•ะะ† / ะะะะ•ะกะ•ะะ† ะšะžะะขะะšะขะ˜", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "ะŸะžะ”ะ†ะ‡ ะŸะ†ะ”ะขะ’ะ•ะ ะ”ะ–ะ•ะะ† ะ”ะ–ะ•ะ ะ•ะ›ะะœะ˜ ยท ะ‘ะ•ะ— ะ†ะœะ†ะขะžะ’ะะะ˜ะฅ ะะžะ’ะ˜ะ", "LATEST LOCATION-MATCHED REPORTING": "ะžะกะขะะะะ† ะŸะฃะ‘ะ›ะ†ะšะะฆะ†ะ‡ ะ—ะ ะ ะžะ—ะขะะจะฃะ’ะะะะฏะœ", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ะ—ะะŸะ˜ะข ะ—ะ ะ ะžะ—ะขะะจะฃะ’ะะะะฏะœ ยท ะะ•ะฉะžะ”ะะ’ะะ†", + "{source} ยท LAST {hours} H": "{source} ยท ะ—ะ {hours} ะ“ะžะ”", "LOCAL INFO": "ะœะ†ะกะฆะ•ะ’ะ† ะ”ะะะ†", "PLACE / CONDITIONS / POSITION": "ะœะ†ะกะฆะ• / ะฃะœะžะ’ะ˜ / ะŸะžะ—ะ˜ะฆะ†ะฏ", "TIME UNKNOWN": "ะงะะก ะะ•ะ’ะ†ะ”ะžะœะ˜ะ™", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 828baa256..912582289 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1577,14 +1577,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "ๆฏ 9 ็ง’่‡ชๅŠจ่ฝฎๆข็ฎ€ๆŠฅ้กต้ข๏ผˆไฟกๅท โ†’ ๆ–ฐ้—ป โ†’ ๆœฌๅœฐ๏ผ‰ใ€‚ๆ‚ฌๅœๆˆ–่š็„ฆ้ขๆฟๆ—ถๆš‚ๅœใ€‚ๆ— ่ฎบๆ˜ฏๅฆ่ฝฎๆข๏ผŒๅฎžๆ—ถไฟกๅทๆ•ฐๆฎ้ƒฝไผšๆŒ็ปญๅˆทๆ–ฐใ€‚", "ACQUIRING REGIONAL NEWS": "ๆญฃๅœจ่Žทๅ–ๅœฐๅŒบๆ–ฐ้—ป", "REGIONAL NEWS UNAVAILABLE": "ๅœฐๅŒบๆ–ฐ้—ปไธๅฏ็”จ", + "CACHED ยท REFRESH FAILED": "็ผ“ๅญ˜ ยท ๅˆทๆ–ฐๅคฑ่ดฅ", "RESOLVING REGION": "ๆญฃๅœจ่งฃๆžๅŒบๅŸŸ", "REGION UNAVAILABLE": "ๅŒบๅŸŸไธๅฏ็”จ", - "NO RECENT LOCATION MATCHES": "ๆš‚ๆ— ่ฟ‘ๆœŸไฝ็ฝฎๅŒน้…็ป“ๆžœ", + "NO NEWS IN THE LAST {hours} H": "่ฟ‡ๅŽป {hours} ๅฐๆ—ถๆ— ๆ–ฐ้—ป", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "ไบ‘้‡ {pct}%", "CLOUD UNKNOWN": "ไบ‘้‡ๆœช็Ÿฅ", - "{source} ยท LOCATION QUERY": "{source} ยท ไฝ็ฝฎๆŸฅ่ฏข", + "{source} ยท CACHED {age}": "{source} ยท ็ผ“ๅญ˜ {age}", "REGIONAL NEWS": "ๅœฐๅŒบๆ–ฐ้—ป", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท {radius} ็ฉบ/ๆตท็›‘่ง†็ช—ๅฃ", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท ่ฎพๆ–ฝ {coverage}", @@ -1642,7 +1643,7 @@ "OBSERVED / MAPPED PINGS": "่ง‚ๆต‹ / ๅœฐๅ›พๆ ‡ๆณจ็›ฎๆ ‡", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "ๆฅๆบๅฏๆŸฅ็š„ไบ‹ไปถ ยท ๆ— ๅˆๆˆๆ–ฐ้—ป", "LATEST LOCATION-MATCHED REPORTING": "ๅฝ“ๅ‰ๅœฐๅŒบๆœ€ๆ–ฐๆŠฅ้“", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ไฝ็ฝฎๆŸฅ่ฏข ยท ่ฟ‘ๆœŸ", + "{source} ยท LAST {hours} H": "{source} ยท ่ฟ‡ๅŽป {hours} ๅฐๆ—ถ", "LOCAL INFO": "ๆœฌๅœฐไฟกๆฏ", "PLACE / CONDITIONS / POSITION": "ๅœฐ็‚น / ็Šถๅ†ต / ไฝ็ฝฎ", "TIME UNKNOWN": "ๆ—ถ้—ดๆœช็Ÿฅ", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e08b69304..628c71284 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1567,14 +1567,15 @@ "Cycle briefing pages automatically every 9 seconds (Signals โ†’ News โ†’ Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "ๆฏ 9 ็ง’่‡ชๅ‹•่ผชๆ›็ฐกๅ ฑ้ ้ข๏ผˆไฟก่™Ÿ โ†’ ๆ–ฐ่ž โ†’ ๆœฌๅœฐ๏ผ‰ใ€‚ๆ‡ธๅœๆˆ–่š็„ฆ้ขๆฟๆ™‚ๆšซๅœใ€‚็„ก่ซ–ๆ˜ฏๅฆ่ผชๆ›๏ผŒๅฏฆๆ™‚ไฟก่™Ÿๆ•ธๆ“š้ƒฝๆœƒๆŒ็บŒๅˆทๆ–ฐใ€‚", "ACQUIRING REGIONAL NEWS": "ๆญฃๅœจ็ฒๅ–ๅœฐๅ€ๆ–ฐ่ž", "REGIONAL NEWS UNAVAILABLE": "ๅœฐๅ€ๆ–ฐ่žไธๅฏ็”จ", + "CACHED ยท REFRESH FAILED": "ๅฟซๅ– ยท ้‡ๆ–ฐๆ•ด็†ๅคฑๆ•—", "RESOLVING REGION": "ๆญฃๅœจ่งฃๆžๅ€ๅŸŸ", "REGION UNAVAILABLE": "ๅ€ๅŸŸไธๅฏ็”จ", - "NO RECENT LOCATION MATCHES": "ๆšซ็„ก่ฟ‘ๆœŸไฝ็ฝฎๅŒน้…็ตๆžœ", + "NO NEWS IN THE LAST {hours} H": "้ŽๅŽป {hours} ๅฐๆ™‚็„กๆ–ฐ่ž", "{domain} ยท {age}": "{domain} ยท {age}", "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "้›ฒ้‡ {pct}%", "CLOUD UNKNOWN": "้›ฒ้‡ๆœช็Ÿฅ", - "{source} ยท LOCATION QUERY": "{source} ยท ไฝ็ฝฎๆŸฅ่ฉข", + "{source} ยท CACHED {age}": "{source} ยท ๅฟซๅ– {age}", "REGIONAL NEWS": "ๅœฐๅ€ๆ–ฐ่ž", "{label} ยท {radius} AIR/SEA WINDOW": "{label} ยท {radius} ็ฉบ/ๆตท็›ฃ่ฆ–็ช—ๅฃ", "{scope} ยท INSTALLATIONS {coverage}": "{scope} ยท ่จญๆ–ฝ {coverage}", @@ -1632,7 +1633,7 @@ "OBSERVED / MAPPED PINGS": "่ง€ๆธฌ / ๅœฐๅœ–ๆจ™่จป็›ฎๆจ™", "SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS": "ไพ†ๆบๅฏๆŸฅ็š„ไบ‹ไปถ ยท ็„กๅˆๆˆๆ–ฐ่ž", "LATEST LOCATION-MATCHED REPORTING": "็•ถๅ‰ๅœฐๅ€ๆœ€ๆ–ฐๅ ฑ้“", - "{source} ยท LOCATION QUERY ยท RECENT": "{source} ยท ไฝ็ฝฎๆŸฅ่ฉข ยท ่ฟ‘ๆœŸ", + "{source} ยท LAST {hours} H": "{source} ยท ้ŽๅŽป {hours} ๅฐๆ™‚", "LOCAL INFO": "ๆœฌๅœฐไฟกๆฏ", "PLACE / CONDITIONS / POSITION": "ๅœฐ้ปž / ็‹€ๆณ / ไฝ็ฝฎ", "TIME UNKNOWN": "ๆ™‚้–“ๆœช็Ÿฅ", diff --git a/src/ui/cockpitBriefPages.js b/src/ui/cockpitBriefPages.js index e92488831..9f75c83d8 100644 --- a/src/ui/cockpitBriefPages.js +++ b/src/ui/cockpitBriefPages.js @@ -1,9 +1,29 @@ /** * The Cockpit briefing's pages and the state it starts in. It needs only the - * i18n module, so the controller and the view can both read it. + * i18n module and the regional-news window, so the controller and the view + * can both read it. */ +import { REGIONAL_NEWS_WINDOW_HOURS } from '../data/regionalNewsWindow.js'; import { msg, t } from '../i18n/index.js'; +// i18n-ignore: provider name +/** The news feed named before any briefing has answered. */ +export const DEFAULT_NEWS_SOURCE = 'GOOGLE NEWS RSS'; + +/** + * The Regional News footer for a fresh, empty or unavailable list: the feed + * and the window its articles are held to. + * @param {string} [source] + * @param {number} [hours] + * @returns {string} + */ +export function newsWindowFooter( + source = DEFAULT_NEWS_SOURCE, + hours = REGIONAL_NEWS_WINDOW_HOURS, +) { + return t('{source} ยท LAST {hours} H', { source, hours }); +} + export const COCKPIT_BRIEF_PAGES = [ { id: 'signals', @@ -15,8 +35,7 @@ export const COCKPIT_BRIEF_PAGES = [ id: 'news', kickerKey: msg('REGIONAL NEWS'), subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'), - sourceKey: msg('{source} ยท LOCATION QUERY ยท RECENT'), - sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name + // The news footer comes from the news state (`news.footer`), not here. }, { id: 'local', @@ -60,6 +79,7 @@ export function createBriefState() { statusHidden: false, state: null, status: t('ACQUIRING REGIONAL NEWS'), + footer: newsWindowFooter(), articles: [], }, local: { diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index a5c6d8c5a..738c8af80 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -12,7 +12,14 @@ import { COCKPIT_BRIEF_PAGES, CYCLE_HELP_KEYS, createBriefState, + newsWindowFooter, } from './cockpitBriefPages.js'; +import { + recentRegionalArticles, + regionalNewsWindowHours, +} from '../data/regionalNewsWindow.js'; + +const NEWS_ROWS = 4; // regionalModel.weatherCodeLabel is shared with node code and returns // English; translate it here. @@ -52,9 +59,13 @@ export function showBriefPage(index, { manual = false } = {}) { kicker: t(page.kickerKey), subtitle: t(page.subtitleKey), position: `${this.briefPageIndex + 1} / ${count}`, - source: page.sourceKey - ? t(page.sourceKey, { source: page.sourceName }) - : page.source, + // The news footer follows the news state, so it survives page changes. + source: + page.id === 'news' + ? this.brief.news.footer + : page.sourceKey + ? t(page.sourceKey) + : page.source, }); this.publishBriefView(); if (manual && this.briefAutoRotateEnabled) @@ -196,8 +207,8 @@ export function maybeRefreshRegionalBrief(info) { subjectId !== this.regionalBriefSubjectId ) return; - this.regionalBrief = payload; - this.renderRegionalBrief(payload, info); + this.regionalBrief = withCachedNews(payload, this.regionalBrief); + this.renderRegionalBrief(this.regionalBrief, info); }) .catch((error) => { if ( @@ -206,7 +217,11 @@ export function maybeRefreshRegionalBrief(info) { requestToken === this.regionalBriefRequestToken && subjectId === this.regionalBriefSubjectId ) { - this.renderRegionalBriefStatus('unavailable', info); + if (this.regionalBrief) { + const previous = this.regionalBrief; + this.regionalBrief = cachedNews(previous, previous); + this.renderRegionalBrief(this.regionalBrief, info); + } else this.renderRegionalBriefStatus('unavailable', info); } }) .finally(() => { @@ -215,6 +230,75 @@ export function maybeRefreshRegionalBrief(info) { }); } +/** + * A brief whose news comes from `previous` after a refresh that brought none: + * the earlier articles stay, marked `cached` so they are labelled as such. + * @param {object} payload The brief to keep everything else from. + * @param {object} previous The last brief rendered for this subject. + */ +function cachedNews(payload, previous) { + return { + ...payload, + newsStatus: 'cached', + newsSource: previous.newsSource, + newsWindowHours: previous.newsWindowHours, + newsFetchedAt: previous.newsFetchedAt ?? previous.retrievedAt ?? null, + articles: previous.articles, + }; +} + +// A refresh whose news read failed keeps the articles from before it. +function withCachedNews(payload, previous) { + return previous && payload?.newsStatus === 'unavailable' + ? cachedNews(payload, previous) + : payload; +} + +/** + * The Regional News state a brief presents. `ready` and `cached` have rows + * inside the window; `cached` is a refresh that failed (a client-side failure, + * or the local proxy's `stale` answer). `empty` is a source that answered with + * nothing in the window; anything else is `unavailable`. + * @returns {{state: string, status: string, footer: string, articles: object[]}} + */ +function regionalNewsView(payload) { + const hours = regionalNewsWindowHours(payload?.newsWindowHours); + // Old caches and the local path are held to the window here too. + const articles = recentRegionalArticles(payload?.articles, { + windowHours: hours, + limit: NEWS_ROWS, + }); + const source = String( + payload?.newsSource || t('REGIONAL NEWS'), + ).toUpperCase(); + const refreshFailed = + payload?.newsStatus === 'cached' || payload?.status === 'stale'; + if (articles.length && refreshFailed) + return { + state: 'cached', + status: t('CACHED ยท REFRESH FAILED'), + footer: t('{source} ยท CACHED {age}', { + source, + age: formatCockpitBriefAge( + payload?.newsFetchedAt ?? payload?.retrievedAt, + ), + }), + articles, + }; + const answered = + !refreshFailed && ['ready', 'empty'].includes(payload?.newsStatus); + return { + state: articles.length ? 'ready' : answered ? 'empty' : 'unavailable', + status: articles.length + ? '' + : answered + ? t('NO NEWS IN THE LAST {hours} H', { hours }) + : t('REGIONAL NEWS UNAVAILABLE'), + footer: newsWindowFooter(source, hours), + articles, + }; +} + export function renderRegionalBriefStatus(status, info) { const { news, local } = this.brief; news.statusHidden = false; @@ -223,7 +307,10 @@ export function renderRegionalBriefStatus(status, info) { status === 'loading' ? t('ACQUIRING REGIONAL NEWS') : t('REGIONAL NEWS UNAVAILABLE'); - if (status === 'unavailable') news.articles = []; + news.footer = newsWindowFooter(); + // Only called with no brief for this subject: any rows are another's. + news.articles = []; + if (this.briefPageIndex === 1) this.brief.source = news.footer; if (status === 'loading') local.place = t('RESOLVING REGION'); if (status === 'unavailable') local.place = t('REGION UNAVAILABLE'); local.coordinates = localCoordinates(info); @@ -231,7 +318,7 @@ export function renderRegionalBriefStatus(status, info) { } export function renderRegionalBrief(payload, info) { - const articles = Array.isArray(payload?.articles) ? payload.articles : []; + const { state, status, footer, articles } = regionalNewsView(payload); const seen = new Map(); const storyKey = (url) => { const count = seen.get(url) ?? 0; @@ -244,13 +331,11 @@ export function renderRegionalBrief(payload, info) { const placeLabel = payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE'); this.brief.news = { - statusHidden: articles.length > 0, - state: payload?.newsStatus || 'unavailable', - status: - payload?.newsStatus === 'empty' - ? t('NO RECENT LOCATION MATCHES') - : t('REGIONAL NEWS UNAVAILABLE'), - articles: articles.slice(0, 4).map((article) => ({ + statusHidden: state === 'ready', + state, + status, + footer, + articles: articles.map((article) => ({ // A row keeps its node, and so its focus, only for the same story. key: storyKey(article.url), url: article.url, @@ -284,11 +369,7 @@ export function renderRegionalBrief(payload, info) { : 'โ€”', }; this.brief.regionalStatus = payload?.status || 'partial'; - if (this.briefPageIndex === 1) { - this.brief.source = t('{source} ยท LOCATION QUERY', { - source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(), - }); - } + if (this.briefPageIndex === 1) this.brief.source = footer; this.publishBriefView(); this.scheduleContextLayout(); } diff --git a/src/ui/cockpitBriefing.test.mjs b/src/ui/cockpitBriefing.test.mjs index 7018c8f79..1b3fbf0bf 100644 --- a/src/ui/cockpitBriefing.test.mjs +++ b/src/ui/cockpitBriefing.test.mjs @@ -3,11 +3,14 @@ import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { createPinia } from 'pinia'; import { createApp, nextTick } from 'vue'; import { setLocale } from '../i18n/index.js'; import { createBriefState } from './cockpitBriefPages.js'; import { + maybeRefreshRegionalBrief, publishBriefView, renderRegionalBrief, renderRegionalBriefStatus, @@ -88,10 +91,7 @@ test('pages, the cycle toggle and the source follow the controller', async (t) = await nextTick(); assert.equal(text('#cockpit-brief-kicker'), 'REGIONAL NEWS'); assert.equal(text('#cockpit-brief-position'), '2 / 3'); - assert.equal( - text('#cockpit-brief-source'), - 'GOOGLE NEWS RSS ยท LOCATION QUERY ยท RECENT', - ); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท LAST 24 H'); assert.deepEqual(visiblePages(), ['news']); assert.deepEqual(current(), ['false', 'true', 'false']); assert.equal($('#cockpit-signal-stream').dataset.briefPage, 'news'); @@ -129,7 +129,8 @@ test('regional news and local conditions render what the region returned', async url: `https://example.com/${index}`, title: `Story ${index}`, domain: 'example.com', - publishedAt: new Date().toISOString(), + // Newest first is the shown order, so Story 0 is the newest. + publishedAt: new Date(Date.now() - index * 60_000).toISOString(), })), weather: { temperatureC: 21.6, @@ -159,7 +160,7 @@ test('regional news and local conditions render what the region returned', async assert.equal(text('#cockpit-local-condition'), 'RAIN'); assert.equal(text('#cockpit-local-cloud'), 'CLOUD 40%'); assert.equal(text('#cockpit-local-precipitation'), '1.3'); - assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LOCATION QUERY'); + assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LAST 24 H'); assert.equal($('#cockpit-signal-stream').dataset.regionalStatus, 'ok'); renderRegionalBriefStatus.call(owner, 'unavailable', {}); await nextTick(); @@ -210,26 +211,246 @@ test('a refreshed news list keeps a focused story on its own link', async (t) => assert.equal($('#cockpit-news-list').textContent.includes('Story y'), true); }); -test('the news source names the feed only while it is current on page 2', async (t) => { +// This contract changed on purpose: the footer used to fall back to a +// static "ยท RECENT" on every page change, which promised nothing. It now +// follows the news state and names the window it holds articles to. +test('the news footer follows the news state across page changes, not a static RECENT', async (t) => { const { text, owner } = fixture(t); showBriefPage.call(owner, 1); - renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO); + renderRegionalBrief.call( + owner, + { newsStatus: 'ready', newsSource: 'wire', newsWindowHours: 24, articles: [story('a')] }, + INFO, + ); await nextTick(); - assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LOCATION QUERY'); + assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LAST 24 H'); showBriefPage.call(owner, 2); showBriefPage.call(owner, 1); await nextTick(); - assert.equal( - text('#cockpit-brief-source'), - 'GOOGLE NEWS RSS ยท LOCATION QUERY ยท RECENT', - ); + assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LAST 24 H'); showBriefPage.call(owner, 0); - renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO); + renderRegionalBrief.call( + owner, + { newsStatus: 'ready', newsSource: 'wire', newsWindowHours: 12, articles: [story('a')] }, + INFO, + ); await nextTick(); + // Off page 2 the footer is the current page's. assert.equal( text('#cockpit-brief-source'), 'SOURCE-BACKED EVENTS ยท NO SYNTHETIC NEWS', ); + showBriefPage.call(owner, 1); + await nextTick(); + assert.equal(text('#cockpit-brief-source'), 'WIRE ยท LAST 12 H'); +}); + +test('the news banner is hidden by the stylesheet, not only by the attribute', async (t) => { + // happy-dom applies no author CSS by itself, so `hidden` alone proves + // nothing: `.cockpit-brief-status { display: grid }` used to beat it. + const css = readFileSync( + fileURLToPath(new URL('./styles/cockpit.css', import.meta.url)), + 'utf8', + ); + assert.match(css, /\.cockpit-brief-status\[hidden\]\s*\{\s*display:\s*none;?\s*\}/); + const { $, owner } = fixture(t); + const style = document.head.appendChild(document.createElement('style')); + t.after(() => style.remove()); + style.textContent = css; + showBriefPage.call(owner, 1); + renderRegionalBrief.call(owner, { newsStatus: 'ready', articles: [story('a')] }, INFO); + await nextTick(); + const banner = $('#cockpit-news-status'); + assert.equal(banner.hidden, true); + assert.equal(getComputedStyle(banner).display, 'none'); + renderRegionalBrief.call(owner, { newsStatus: 'empty', articles: [] }, INFO); + await nextTick(); + assert.equal(getComputedStyle(banner).display, 'grid'); +}); + +const hoursAgo = (hours) => new Date(Date.now() - hours * 3_600_000).toISOString(); + +test('ready, cached, empty and unavailable each label the news differently', async (t) => { + const { $, text, owner } = fixture(t); + showBriefPage.call(owner, 1); + const rows = () => + [...document.querySelectorAll('#cockpit-news-list strong')].map((row) => row.textContent); + const banner = () => ({ + hidden: $('#cockpit-news-status').hidden, + state: $('#cockpit-news-status').dataset.state, + text: text('#cockpit-news-status'), + }); + const ready = { + status: 'ready', + newsStatus: 'ready', + newsSource: 'Google News RSS', + newsWindowHours: 24, + newsFetchedAt: hoursAgo(0.5), + articles: [story('a'), story('b')], + }; + renderRegionalBrief.call(owner, ready, INFO); + await nextTick(); + assert.equal(banner().hidden, true); + assert.equal(banner().state, 'ready'); + assert.deepEqual(rows(), ['Story a', 'Story b']); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท LAST 24 H'); + + // The local proxy's stale answer is a refresh that failed. + renderRegionalBrief.call(owner, { ...ready, status: 'stale' }, INFO); + await nextTick(); + assert.deepEqual(banner(), { + hidden: false, + state: 'cached', + text: 'CACHED ยท REFRESH FAILED', + }); + assert.deepEqual(rows(), ['Story a', 'Story b']); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท CACHED 30M AGO'); + + renderRegionalBrief.call( + owner, + { ...ready, newsStatus: 'empty', newsSource: 'Google News RSS', articles: [] }, + INFO, + ); + await nextTick(); + assert.deepEqual(banner(), { + hidden: false, + state: 'empty', + text: 'NO NEWS IN THE LAST 24 H', + }); + assert.deepEqual(rows(), []); + + renderRegionalBrief.call( + owner, + { ...ready, newsStatus: 'unavailable', newsSource: null, articles: [] }, + INFO, + ); + await nextTick(); + assert.deepEqual(banner(), { + hidden: false, + state: 'unavailable', + text: 'REGIONAL NEWS UNAVAILABLE', + }); + assert.deepEqual(rows(), []); +}); + +test('articles outside the window are dropped even when a server sends them', async (t) => { + const { $, text, owner } = fixture(t); + showBriefPage.call(owner, 1); + const dated = (id, publishedAt) => ({ ...story(id), publishedAt }); + // The reported case: 4, 104, 215 and 165 days old under "RECENT". + renderRegionalBrief.call( + owner, + { + newsStatus: 'ready', + articles: [ + dated('old', hoursAgo(4 * 24)), + dated('older', hoursAgo(104 * 24)), + dated('undated', null), + dated('one', hoursAgo(20)), + dated('two', hoursAgo(2)), + ], + }, + INFO, + ); + await nextTick(); + assert.deepEqual( + [...document.querySelectorAll('#cockpit-news-list strong')].map((row) => row.textContent), + ['Story two', 'Story one'], + ); + assert.equal($('#cockpit-news-status').hidden, true); + renderRegionalBrief.call( + owner, + { newsStatus: 'ready', articles: [dated('old', hoursAgo(25))] }, + INFO, + ); + await nextTick(); + assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 0); + assert.equal(text('#cockpit-news-status'), 'NO NEWS IN THE LAST 24 H'); +}); + +test('a failed refresh keeps the earlier articles as cached, and clears only when there are none', async (t) => { + const { $, text, owner } = fixture(t); + const answers = []; + Object.assign(owner, { + active: true, + regionalBriefSubjectId: null, + regionalBriefAbort: null, + regionalBriefRequestToken: 0, + regionalBriefFetchedAt: 0, + regionalBrief: null, + renderRegionalBrief, + renderRegionalBriefStatus, + }); + owner.services = { + ...owner.services, + regionalDistanceM: () => 0, + fetchRegionalBrief: () => answers.shift()(), + }; + const refresh = async (answer, info = { ...INFO, icao24: 'abc123' }) => { + answers.push(answer); + owner.regionalBriefFetchedAt = 0; + maybeRefreshRegionalBrief.call(owner, info); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + }; + const rows = () => document.querySelectorAll('#cockpit-news-list li').length; + showBriefPage.call(owner, 1); + + // No earlier brief: an error clears and says unavailable. + await refresh(() => Promise.reject(new Error('503'))); + assert.equal($('#cockpit-news-status').dataset.state, 'unavailable'); + assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE'); + assert.equal(rows(), 0); + + const fetchedAt = hoursAgo(1); + await refresh(() => + Promise.resolve({ + status: 'ready', + newsStatus: 'ready', + newsSource: 'Google News RSS', + newsWindowHours: 24, + newsFetchedAt: fetchedAt, + articles: [story('a'), story('b')], + }), + ); + assert.equal($('#cockpit-news-status').hidden, true); + assert.equal(rows(), 2); + + // A network failure keeps the rows and says so, with their age. + await refresh(() => Promise.reject(new TypeError('Failed to fetch'))); + assert.equal($('#cockpit-news-status').hidden, false); + assert.equal(text('#cockpit-news-status'), 'CACHED ยท REFRESH FAILED'); + assert.equal(rows(), 2); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท CACHED 1H AGO'); + showBriefPage.call(owner, 2); + showBriefPage.call(owner, 1); + await nextTick(); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท CACHED 1H AGO'); + + // So does a brief whose news read failed while weather answered. + await refresh(() => + Promise.resolve({ status: 'partial', newsStatus: 'unavailable', articles: [] }), + ); + assert.equal(text('#cockpit-news-status'), 'CACHED ยท REFRESH FAILED'); + assert.equal(rows(), 2); + + // A successful refresh clears the cached label; a true empty clears rows. + await refresh(() => + Promise.resolve({ status: 'ready', newsStatus: 'empty', newsSource: 'Google News RSS', articles: [] }), + ); + assert.equal(text('#cockpit-news-status'), 'NO NEWS IN THE LAST 24 H'); + assert.equal(rows(), 0); + assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS ยท LAST 24 H'); + + // Another aircraft never inherits the last one's rows. + await refresh(() => Promise.resolve({ newsStatus: 'ready', articles: [story('c')] })); + assert.equal(rows(), 1); + await refresh( + () => Promise.reject(new Error('503')), + { ...INFO, icao24: 'def456' }, + ); + assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE'); + assert.equal(rows(), 0); }); test('an unchanged position publishes nothing new', (t) => { diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d7ad845b6..3a56e7963 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -1818,7 +1818,13 @@ html:not([lang='en']) #cockpit-brief-auto { letter-spacing: 0.13em; text-align: center; } -.cockpit-brief-status[data-state='unavailable'] { +/* `display: grid` above would otherwise beat the UA [hidden] rule and keep a + stale banner over a populated list. */ +.cockpit-brief-status[hidden] { + display: none; +} +.cockpit-brief-status[data-state='unavailable'], +.cockpit-brief-status[data-state='cached'] { color: #d5a863; } .cockpit-local-place { From 2d9d5189467e34fc0f28dd0b26876de2fc03ae65 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 02:26:46 +0100 Subject: [PATCH 10/86] Add the news report model, markers and source The pure half of a news layer: validated records for a news server's `/api/news/hotspots|feed|outline` contract, a presentation model and canvas markers and picture cards, and a fetch client. - Colour carries severity only (0 no physical event, 1 minor, 2 significant, 3 major), never a side; the category is the icon, the rim is attestation (confirmed, a claim, one side's own press), size grows with sources and markers fade with age. - Cards say "Reported by {side} sources" and mark official channels; a machine-read English headline is labelled as such. - `createNewsSource` reads same-origin routes with a 15 s timeout, a 4 MiB cap and record-by-record validation; `NEWS_API_PATHS` and `newsQueryAllowed` let a host proxy exactly what the client sends. A 503 marked unavailable means "not configured": no retries. - Exported as `./layers/news/source`, with its own package boundary. --- package.json | 1 + scripts/package-boundaries.json | 11 + src/i18n/locales/ar.json | 56 +++ src/i18n/locales/de.json | 52 +++ src/i18n/locales/es.json | 53 +++ src/i18n/locales/fr.json | 53 +++ src/i18n/locales/it.json | 53 +++ src/i18n/locales/ja.json | 51 +++ src/i18n/locales/pt.json | 53 +++ src/i18n/locales/ru.json | 54 +++ src/i18n/locales/uk.json | 54 +++ src/i18n/locales/zh-CN.json | 51 +++ src/i18n/locales/zh-Hant.json | 51 +++ src/i18n/same-as-english.json | 22 ++ src/layers/news/cards.js | 206 ++++++++++ src/layers/news/model.js | 643 ++++++++++++++++++++++++++++++++ src/layers/news/model.test.mjs | 444 ++++++++++++++++++++++ src/layers/news/records.js | 325 ++++++++++++++++ src/layers/news/source.js | 162 ++++++++ src/layers/news/source.test.mjs | 164 ++++++++ src/layers/news/sprites.js | 94 +++++ 21 files changed, 2653 insertions(+) create mode 100644 src/layers/news/cards.js create mode 100644 src/layers/news/model.js create mode 100644 src/layers/news/model.test.mjs create mode 100644 src/layers/news/records.js create mode 100644 src/layers/news/source.js create mode 100644 src/layers/news/source.test.mjs create mode 100644 src/layers/news/sprites.js diff --git a/package.json b/package.json index 42121974f..c267cef79 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "./layers/gnss/source": "./src/layers/gnss/source.js", "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js", + "./layers/news/source": "./src/layers/news/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6ba148306..328112590 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -4817,6 +4817,17 @@ ], "external": [] }, + "news-source": { + "exports": ["./layers/news/source"], + "modules": [ + "src/layers/news/source.js", + "src/layers/news/records.js", + "src/sources/httpBody.js", + "src/sources/capability.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 186bd4e1e..59cd4caa6 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,4 +1,60 @@ { + "News feed unavailable": "ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ู…ุชุงุญ", + "Disputed: {name}": "ู…ู†ุทู‚ุฉ ู…ุชู†ุงุฒุน ุนู„ูŠู‡ุง: {name}", + "Administered by {holder} ยท claimed by {claimant}": "ุชุฏูŠุฑู‡ุง {holder} ยท ุชุทุงู„ุจ ุจู‡ุง {claimant}", + "Self-administered ยท claimed by {claimant}": "ุชุฏูŠุฑ ู†ูุณู‡ุง ยท ุชุทุงู„ุจ ุจู‡ุง {claimant}", + "UN-patrolled zone": "ู…ู†ุทู‚ุฉ ุชุณูŠู‘ุฑ ููŠู‡ุง ุงู„ุฃู…ู… ุงู„ู…ุชุญุฏุฉ ุฏูˆุฑูŠุงุช", + "Claimed by neither neighbour": "ู„ุง ูŠุทุงู„ุจ ุจู‡ุง ุฃูŠ ู…ู† ุงู„ุฌุงุฑูŠู†", + "Video": "ููŠุฏูŠูˆ", + "Photos": "ุตูˆุฑ", + "Claimed by {who}": "ุงุฏุนุงุก: {who}", + "Claimed": "ุงุฏุนุงุก", + "Not yet confirmed by others": "ู„ู… ูŠุคูƒุฏู‡ ุขุฎุฑูˆู† ุจุนุฏ", + "Reported by {side} sources": "ุฃูˆุฑุฏุชู‡ ู…ุตุงุฏุฑ {side}", + "Reported by {sides}": "ุฃูˆุฑุฏุชู‡ {sides}", + "{side} sources": "ู…ุตุงุฏุฑ {side}", + "Official": "ุฑุณู…ูŠ", + "Reported by both sides": "ุฃูˆุฑุฏู‡ ุงู„ุทุฑูุงู†", + "Independently reported": "ุฃูุจู„ุบ ุนู†ู‡ ุจุดูƒู„ ู…ุณุชู‚ู„", + "Single source": "ู…ุตุฏุฑ ูˆุงุญุฏ", + "International": "ุฏูˆู„ูŠุฉ", + "Independent": "ู…ุณุชู‚ู„ุฉ", + "Unattributed": "ุบูŠุฑ ู…ู†ุณูˆุจ", + "Outside party": "ุทุฑู ุฎุงุฑุฌูŠ", + "Ukraineโ€“Russia": "ุฃูˆูƒุฑุงู†ูŠุงโ€“ุฑูˆุณูŠุง", + "Israelโ€“Gazaโ€“Lebanon": "ุฅุณุฑุงุฆูŠู„โ€“ุบุฒุฉโ€“ู„ุจู†ุงู†", + "Iranโ€“Hormuz": "ุฅูŠุฑุงู†โ€“ู‡ุฑู…ุฒ", + "Yemenโ€“Red Sea": "ุงู„ูŠู…ู†โ€“ุงู„ุจุญุฑ ุงู„ุฃุญู…ุฑ", + "Sudan": "ุงู„ุณูˆุฏุงู†", + "Sahel": "ุงู„ุณุงุญู„", + "Elsewhere": "ุฃู…ุงูƒู† ุฃุฎุฑู‰", + "Strike": "ุถุฑุจุฉ", + "Alert": "ุฅู†ุฐุงุฑ", + "Air defence": "ุฏูุงุน ุฌูˆูŠ", + "Ground fighting": "ู‚ุชุงู„ ุจุฑูŠ", + "Casualties": "ุฅุตุงุจุงุช", + "Humanitarian": "ุฅู†ุณุงู†ูŠ", + "Disaster": "ูƒุงุฑุซุฉ", + "Protest": "ุงุญุชุฌุงุฌ", + "Political": "ุณูŠุงุณูŠ", + "No physical event": "ู„ุง ุญุฏุซ ู…ูŠุฏุงู†ูŠ", + "Minor incident": "ุญุงุฏุซ ุจุณูŠุท", + "Significant": "ูƒุจูŠุฑ", + "Major": "ุฌุณูŠู…", + "age|now": "ุงู„ุขู†", + "{minutes} min": "{minutes} ุฏ", + "{hours} h": "{hours} ุณ", + "{count} sources": { + "zero": "{count} ู…ุตุฏุฑ", + "one": "ู…ุตุฏุฑ ูˆุงุญุฏ ({count})", + "two": "ู…ุตุฏุฑุงู† ({count})", + "few": "{count} ู…ุตุงุฏุฑ", + "many": "{count} ู…ุตุฏุฑู‹ุง", + "other": "{count} ู…ุตุฏุฑ" + }, + "News feed not configured on this server": "ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ู…ู‡ูŠุฃ ุนู„ู‰ ู‡ุฐุง ุงู„ุฎุงุฏู…", + "Malformed news hotspots response": "ุงุณุชุฌุงุจุฉ ุจุคุฑ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ุตุงู„ุญุฉ", + "Malformed news feed response": "ุงุณุชุฌุงุจุฉ ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ุตุงู„ุญุฉ", "NO PLACE LEFT BEHIND": "ู„ุง ู…ูƒุงู† ูŠูุชุฑูƒ ุฎู„ูู†ุง", "Initializing photorealistic world...": "ุฌุงุฑู ุชู‡ูŠุฆุฉ ุงู„ุนุงู„ู… ุงู„ูˆุงู‚ุนูŠโ€ฆ", "Configuring viewer...": "ุฌุงุฑู ุฅุนุฏุงุฏ ุงู„ุนุงุฑุถโ€ฆ", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0fa269e20..9421361bc 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,4 +1,56 @@ { + "News feed unavailable": "Nachrichtenfeed nicht verfรผgbar", + "Disputed: {name}": "Umstritten: {name}", + "Administered by {holder} ยท claimed by {claimant}": "Verwaltet von {holder} ยท beansprucht von {claimant}", + "Self-administered ยท claimed by {claimant}": "Selbstverwaltet ยท beansprucht von {claimant}", + "UN-patrolled zone": "Von der UNO รผberwachte Zone", + "Claimed by neither neighbour": "Von keinem Nachbarn beansprucht", + "Video": "Video", + "Photos": "Fotos", + "Claimed by {who}": "Behauptet von {who}", + "Claimed": "Behauptung", + "Not yet confirmed by others": "Noch nicht von anderen bestรคtigt", + "Reported by {side} sources": "Gemeldet von Quellen aus {side}", + "Reported by {sides}": "Gemeldet von {sides}", + "{side} sources": "Quellen aus {side}", + "Official": "Offiziell", + "Reported by both sides": "Von beiden Seiten gemeldet", + "Independently reported": "Unabhรคngig gemeldet", + "Single source": "Einzelne Quelle", + "International": "International", + "Independent": "Unabhรคngig", + "Unattributed": "Ohne Zuordnung", + "Outside party": "AuรŸenstehende Partei", + "Ukraineโ€“Russia": "Ukraineโ€“Russland", + "Israelโ€“Gazaโ€“Lebanon": "Israelโ€“Gazaโ€“Libanon", + "Iranโ€“Hormuz": "Iranโ€“Hormus", + "Yemenโ€“Red Sea": "Jemenโ€“Rotes Meer", + "Sudan": "Sudan", + "Sahel": "Sahel", + "Elsewhere": "Anderswo", + "Strike": "Angriff", + "Alert": "Warnung", + "Air defence": "Luftabwehr", + "Ground fighting": "Bodenkรคmpfe", + "Casualties": "Opfer", + "Humanitarian": "Humanitรคr", + "Disaster": "Katastrophe", + "Protest": "Protest", + "Political": "Politik", + "No physical event": "Kein physisches Ereignis", + "Minor incident": "Kleiner Vorfall", + "Significant": "Erheblich", + "Major": "Schwer", + "age|now": "jetzt", + "{minutes} min": "{minutes} Min.", + "{hours} h": "{hours} Std.", + "{count} sources": { + "one": "{count} Quelle", + "other": "{count} Quellen" + }, + "News feed not configured on this server": "Nachrichtenfeed auf diesem Server nicht eingerichtet", + "Malformed news hotspots response": "Fehlerhafte Antwort zu Nachrichten-Brennpunkten", + "Malformed news feed response": "Fehlerhafte Antwort des Nachrichtenfeeds", "Flying to Austin, TX...": "Flug nach Austin, TX...", "Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...", "Configuring viewer...": "Viewer wird konfiguriert...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 078096514..914a85171 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Noticias no disponibles", + "Disputed: {name}": "En disputa: {name}", + "Administered by {holder} ยท claimed by {claimant}": "Administrado por {holder} ยท reclamado por {claimant}", + "Self-administered ยท claimed by {claimant}": "Autogobernado ยท reclamado por {claimant}", + "UN-patrolled zone": "Zona patrullada por la ONU", + "Claimed by neither neighbour": "Ningรบn vecino lo reclama", + "Video": "Vรญdeo", + "Photos": "Fotos", + "Claimed by {who}": "Segรบn {who}", + "Claimed": "Afirmaciรณn", + "Not yet confirmed by others": "Aรบn sin confirmar por otros", + "Reported by {side} sources": "Informado por fuentes de {side}", + "Reported by {sides}": "Informado por {sides}", + "{side} sources": "fuentes de {side}", + "Official": "Oficial", + "Reported by both sides": "Informado por ambos bandos", + "Independently reported": "Informado de forma independiente", + "Single source": "Fuente รบnica", + "International": "Internacional", + "Independent": "Independiente", + "Unattributed": "Sin atribuir", + "Outside party": "Parte externa", + "Ukraineโ€“Russia": "Ucraniaโ€“Rusia", + "Israelโ€“Gazaโ€“Lebanon": "Israelโ€“Gazaโ€“Lรญbano", + "Iranโ€“Hormuz": "Irรกnโ€“Ormuz", + "Yemenโ€“Red Sea": "Yemenโ€“Mar Rojo", + "Sudan": "Sudรกn", + "Sahel": "Sahel", + "Elsewhere": "En otros lugares", + "Strike": "Ataque", + "Alert": "Alerta", + "Air defence": "Defensa aรฉrea", + "Ground fighting": "Combates terrestres", + "Casualties": "Vรญctimas", + "Humanitarian": "Humanitario", + "Disaster": "Catรกstrofe", + "Protest": "Protesta", + "Political": "Polรญtico", + "No physical event": "Sin hecho fรญsico", + "Minor incident": "Incidente menor", + "Significant": "Significativo", + "Major": "Grave", + "age|now": "ahora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fuente", + "many": "{count} fuentes", + "other": "{count} fuentes" + }, + "News feed not configured on this server": "Noticias no configuradas en este servidor", + "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", + "Malformed news feed response": "Respuesta de noticias mal formada", "NO PLACE LEFT BEHIND": "NINGรšN RINCร“N SIN VIGILAR", "Initializing photorealistic world...": "Inicializando el mundo fotorrealista...", "Configuring viewer...": "Configurando el visor...", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3ce27c53c..f01ca2956 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Fil dโ€™actualitรฉs indisponible", + "Disputed: {name}": "Contestรฉ : {name}", + "Administered by {holder} ยท claimed by {claimant}": "Administrรฉ par {holder} ยท revendiquรฉ par {claimant}", + "Self-administered ยท claimed by {claimant}": "Autoadministrรฉ ยท revendiquรฉ par {claimant}", + "UN-patrolled zone": "Zone patrouillรฉe par lโ€™ONU", + "Claimed by neither neighbour": "Revendiquรฉ par aucun voisin", + "Video": "Vidรฉo", + "Photos": "Photos", + "Claimed by {who}": "Selon {who}", + "Claimed": "Affirmation", + "Not yet confirmed by others": "Pas encore confirmรฉ par dโ€™autres", + "Reported by {side} sources": "Rapportรฉ par des sources {side}", + "Reported by {sides}": "Rapportรฉ par {sides}", + "{side} sources": "sources {side}", + "Official": "Officiel", + "Reported by both sides": "Rapportรฉ par les deux camps", + "Independently reported": "Rapportรฉ de faรงon indรฉpendante", + "Single source": "Source unique", + "International": "Internationale", + "Independent": "Indรฉpendante", + "Unattributed": "Non attribuรฉ", + "Outside party": "Partie extรฉrieure", + "Ukraineโ€“Russia": "Ukraineโ€“Russie", + "Israelโ€“Gazaโ€“Lebanon": "Israรซlโ€“Gazaโ€“Liban", + "Iranโ€“Hormuz": "Iranโ€“Ormuz", + "Yemenโ€“Red Sea": "Yรฉmenโ€“mer Rouge", + "Sudan": "Soudan", + "Sahel": "Sahel", + "Elsewhere": "Ailleurs", + "Strike": "Frappe", + "Alert": "Alerte", + "Air defence": "Dรฉfense aรฉrienne", + "Ground fighting": "Combats au sol", + "Casualties": "Victimes", + "Humanitarian": "Humanitaire", + "Disaster": "Catastrophe", + "Protest": "Manifestation", + "Political": "Politique", + "No physical event": "Aucun รฉvรฉnement physique", + "Minor incident": "Incident mineur", + "Significant": "Important", + "Major": "Majeur", + "age|now": "maintenant", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} source", + "many": "{count} sources", + "other": "{count} sources" + }, + "News feed not configured on this server": "Fil dโ€™actualitรฉs non configurรฉ sur ce serveur", + "Malformed news hotspots response": "Rรฉponse des foyers dโ€™actualitรฉ mal formรฉe", + "Malformed news feed response": "Rรฉponse du fil dโ€™actualitรฉs mal formรฉe", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'รฉpingle, Esc pour annuler.", "Click where the pin goes.": "Cliquez ร  l'endroit oรน poser l'รฉpingle.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 36f25a0cf..dcaf4855c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Feed delle notizie non disponibile", + "Disputed: {name}": "Conteso: {name}", + "Administered by {holder} ยท claimed by {claimant}": "Amministrato da {holder} ยท rivendicato da {claimant}", + "Self-administered ยท claimed by {claimant}": "Autogovernato ยท rivendicato da {claimant}", + "UN-patrolled zone": "Zona pattugliata dallโ€™ONU", + "Claimed by neither neighbour": "Non rivendicato da nessun vicino", + "Video": "Video", + "Photos": "Foto", + "Claimed by {who}": "Secondo {who}", + "Claimed": "Affermazione", + "Not yet confirmed by others": "Non ancora confermato da altri", + "Reported by {side} sources": "Riportato da fonti {side}", + "Reported by {sides}": "Riportato da {sides}", + "{side} sources": "fonti {side}", + "Official": "Ufficiale", + "Reported by both sides": "Riportato da entrambe le parti", + "Independently reported": "Riportato in modo indipendente", + "Single source": "Fonte unica", + "International": "Internazionale", + "Independent": "Indipendente", + "Unattributed": "Non attribuito", + "Outside party": "Parte esterna", + "Ukraineโ€“Russia": "Ucrainaโ€“Russia", + "Israelโ€“Gazaโ€“Lebanon": "Israeleโ€“Gazaโ€“Libano", + "Iranโ€“Hormuz": "Iranโ€“Hormuz", + "Yemenโ€“Red Sea": "Yemenโ€“Mar Rosso", + "Sudan": "Sudan", + "Sahel": "Sahel", + "Elsewhere": "Altrove", + "Strike": "Attacco", + "Alert": "Allerta", + "Air defence": "Difesa aerea", + "Ground fighting": "Combattimenti a terra", + "Casualties": "Vittime", + "Humanitarian": "Umanitario", + "Disaster": "Disastro", + "Protest": "Protesta", + "Political": "Politica", + "No physical event": "Nessun evento fisico", + "Minor incident": "Incidente minore", + "Significant": "Rilevante", + "Major": "Grave", + "age|now": "ora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fonte", + "many": "{count} fonti", + "other": "{count} fonti" + }, + "News feed not configured on this server": "Feed delle notizie non configurato su questo server", + "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", + "Malformed news feed response": "Risposta del feed delle notizie non valida", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", "Click where the pin goes.": "Fai clic dove va il segnaposto.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e586716f8..743fcb71c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "ใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใ‚’ๅˆฉ็”จใงใใพใ›ใ‚“", + "Disputed: {name}": "ไฟ‚ไบ‰ๅœฐ: {name}", + "Administered by {holder} ยท claimed by {claimant}": "{holder}ใŒๆ–ฝๆ”ฟ ยท {claimant}ใŒ้ ˜ๆœ‰ใ‚’ไธปๅผต", + "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ใŒ้ ˜ๆœ‰ใ‚’ไธปๅผต", + "UN-patrolled zone": "ๅ›ฝ้€ฃใŒๅทกๅ›žใ™ใ‚‹ๅœฐๅŸŸ", + "Claimed by neither neighbour": "ใฉใกใ‚‰ใฎ้šฃๅ›ฝใ‚‚้ ˜ๆœ‰ใ‚’ไธปๅผตใ—ใฆใ„ใชใ„", + "Video": "ๅ‹•็”ป", + "Photos": "ๅ†™็œŸ", + "Claimed by {who}": "{who}ใฎไธปๅผต", + "Claimed": "ไธปๅผต", + "Not yet confirmed by others": "ไป–ใ‹ใ‚‰ใฎ็ขบ่ชใฏใพใ ใชใ„", + "Reported by {side} sources": "{side}ๅดใฎๆƒ…ๅ ฑๆบใŒๅ ฑ้“", + "Reported by {sides}": "ๅ ฑ้“: {sides}", + "{side} sources": "{side}ๅดใฎๆƒ…ๅ ฑๆบ", + "Official": "ๅ…ฌๅผ", + "Reported by both sides": "ๅŒๆ–นใŒๅ ฑ้“", + "Independently reported": "็‹ฌ็ซ‹ใ—ใŸๅ ฑ้“ใ‚ใ‚Š", + "Single source": "ๅ˜ไธ€ใฎๆƒ…ๅ ฑๆบ", + "International": "ๅ›ฝ้š›", + "Independent": "็‹ฌ็ซ‹็ณป", + "Unattributed": "ๅ‡บๆ‰€ไธๆ˜Ž", + "Outside party": "็ฌฌไธ‰่€…", + "Ukraineโ€“Russia": "ใ‚ฆใ‚ฏใƒฉใ‚คใƒŠโ€“ใƒญใ‚ทใ‚ข", + "Israelโ€“Gazaโ€“Lebanon": "ใ‚คใ‚นใƒฉใ‚จใƒซโ€“ใ‚ฌใ‚ถโ€“ใƒฌใƒใƒŽใƒณ", + "Iranโ€“Hormuz": "ใ‚คใƒฉใƒณโ€“ใƒ›ใƒซใƒ ใ‚บ", + "Yemenโ€“Red Sea": "ใ‚คใ‚จใƒกใƒณโ€“็ด…ๆตท", + "Sudan": "ใ‚นใƒผใƒ€ใƒณ", + "Sahel": "ใ‚ตใƒ˜ใƒซ", + "Elsewhere": "ใใฎไป–ใฎๅœฐๅŸŸ", + "Strike": "ๆ”ปๆ’ƒ", + "Alert": "่ญฆๅ ฑ", + "Air defence": "้˜ฒ็ฉบ", + "Ground fighting": "ๅœฐไธŠๆˆฆ", + "Casualties": "ๆญปๅ‚ท่€…", + "Humanitarian": "ไบบ้“", + "Disaster": "็ฝๅฎณ", + "Protest": "ๆŠ—่ญฐ", + "Political": "ๆ”ฟๆฒป", + "No physical event": "็‰ฉ็†็š„ใชไบ‹่ฑกใชใ—", + "Minor incident": "ๅฐ่ฆๆจกใชไบ‹ๆกˆ", + "Significant": "้‡ๅคง", + "Major": "ๆทฑๅˆป", + "age|now": "ไปŠ", + "{minutes} min": "{minutes}ๅˆ†", + "{hours} h": "{hours}ๆ™‚้–“", + "{count} sources": { + "other": "ๆƒ…ๅ ฑๆบ {count}ไปถ" + }, + "News feed not configured on this server": "ใ“ใฎใ‚ตใƒผใƒใƒผใงใฏใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใŒ่จญๅฎšใ•ใ‚Œใฆใ„ใพใ›ใ‚“", + "Malformed news hotspots response": "ใƒ‹ใƒฅใƒผใ‚นใฎใƒ›ใƒƒใƒˆใ‚นใƒใƒƒใƒˆๅฟœ็ญ”ใฎๅฝขๅผใŒไธๆญฃใงใ™", + "Malformed news feed response": "ใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใฎๅฟœ็ญ”ใฎๅฝขๅผใŒไธๆญฃใงใ™", "Pick a shape, then click the map.": "ๅ›ณๅฝขใ‚’้ธใ‚“ใงใ‹ใ‚‰ใ€ๅœฐๅ›ณใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใใ ใ•ใ„ใ€‚", "Enter to place the pin, Esc to cancel.": "Enterใงใƒ”ใƒณใ‚’้…็ฝฎใ€Escใงใ‚ญใƒฃใƒณใ‚ปใƒซใ€‚", "Click where the pin goes.": "ใƒ”ใƒณใ‚’็ฝฎใๅ ดๆ‰€ใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใใ ใ•ใ„ใ€‚", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 13d3fbc85..2068c08b6 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1,4 +1,57 @@ { + "News feed unavailable": "Feed de notรญcias indisponรญvel", + "Disputed: {name}": "Em disputa: {name}", + "Administered by {holder} ยท claimed by {claimant}": "Administrado por {holder} ยท reivindicado por {claimant}", + "Self-administered ยท claimed by {claimant}": "Autogovernado ยท reivindicado por {claimant}", + "UN-patrolled zone": "Zona patrulhada pela ONU", + "Claimed by neither neighbour": "Nenhum vizinho o reivindica", + "Video": "Vรญdeo", + "Photos": "Fotos", + "Claimed by {who}": "Segundo {who}", + "Claimed": "Alegaรงรฃo", + "Not yet confirmed by others": "Ainda nรฃo confirmado por outros", + "Reported by {side} sources": "Noticiado por fontes de {side}", + "Reported by {sides}": "Noticiado por {sides}", + "{side} sources": "fontes de {side}", + "Official": "Oficial", + "Reported by both sides": "Noticiado por ambos os lados", + "Independently reported": "Noticiado de forma independente", + "Single source": "Fonte รบnica", + "International": "Internacional", + "Independent": "Independente", + "Unattributed": "Sem atribuiรงรฃo", + "Outside party": "Parte externa", + "Ukraineโ€“Russia": "Ucrรขniaโ€“Rรบssia", + "Israelโ€“Gazaโ€“Lebanon": "Israelโ€“Gazaโ€“Lรญbano", + "Iranโ€“Hormuz": "Irรฃโ€“Ormuz", + "Yemenโ€“Red Sea": "Iรชmenโ€“Mar Vermelho", + "Sudan": "Sudรฃo", + "Sahel": "Sahel", + "Elsewhere": "Outros lugares", + "Strike": "Ataque", + "Alert": "Alerta", + "Air defence": "Defesa aรฉrea", + "Ground fighting": "Combates terrestres", + "Casualties": "Vรญtimas", + "Humanitarian": "Humanitรกrio", + "Disaster": "Desastre", + "Protest": "Protesto", + "Political": "Polรญtico", + "No physical event": "Sem evento fรญsico", + "Minor incident": "Incidente menor", + "Significant": "Significativo", + "Major": "Grave", + "age|now": "agora", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} fonte", + "many": "{count} fontes", + "other": "{count} fontes" + }, + "News feed not configured on this server": "Feed de notรญcias nรฃo configurado neste servidor", + "Malformed news hotspots response": "Resposta de focos de notรญcias malformada", + "Malformed news feed response": "Resposta do feed de notรญcias malformada", "Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.", "Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.", "Click where the pin goes.": "Clique onde o marcador deve ficar.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 766af1005..e6df84b68 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,4 +1,58 @@ { + "News feed unavailable": "ะ›ะตะฝั‚ะฐ ะฝะพะฒะพัั‚ะตะน ะฝะตะดะพัั‚ัƒะฟะฝะฐ", + "Disputed: {name}": "ะกะฟะพั€ะฝะฐั ั‚ะตั€ั€ะธั‚ะพั€ะธั: {name}", + "Administered by {holder} ยท claimed by {claimant}": "ะŸะพะด ัƒะฟั€ะฐะฒะปะตะฝะธะตะผ: {holder} ยท ะฟั€ะตั‚ะตะฝะดัƒะตั‚: {claimant}", + "Self-administered ยท claimed by {claimant}": "ะกะฐะผะพัƒะฟั€ะฐะฒะปัะตะผะฐั ยท ะฟั€ะตั‚ะตะฝะดัƒะตั‚: {claimant}", + "UN-patrolled zone": "ะ—ะพะฝะฐ ะฟะฐั‚ั€ัƒะปะธั€ะพะฒะฐะฝะธั ะžะžะ", + "Claimed by neither neighbour": "ะะต ะฟั€ะตั‚ะตะฝะดัƒะตั‚ ะฝะธ ะพะดะธะฝ ัะพัะตะด", + "Video": "ะ’ะธะดะตะพ", + "Photos": "ะคะพั‚ะพ", + "Claimed by {who}": "ะ—ะฐัะฒะปะตะฝะธะต: {who}", + "Claimed": "ะ—ะฐัะฒะปะตะฝะธะต", + "Not yet confirmed by others": "ะŸะพะบะฐ ะฝะต ะฟะพะดั‚ะฒะตั€ะถะดะตะฝะพ ะดั€ัƒะณะธะผะธ", + "Reported by {side} sources": "ะกะพะพะฑั‰ะฐัŽั‚ ะธัั‚ะพั‡ะฝะธะบะธ {side}", + "Reported by {sides}": "ะกะพะพะฑั‰ะฐัŽั‚: {sides}", + "{side} sources": "ะธัั‚ะพั‡ะฝะธะบะธ {side}", + "Official": "ะžั„ะธั†ะธะฐะปัŒะฝะพ", + "Reported by both sides": "ะกะพะพะฑั‰ะฐัŽั‚ ะพะฑะต ัั‚ะพั€ะพะฝั‹", + "Independently reported": "ะŸะพะดั‚ะฒะตั€ะถะดะตะฝะพ ะฝะตะทะฐะฒะธัะธะผั‹ะผะธ ะธัั‚ะพั‡ะฝะธะบะฐะผะธ", + "Single source": "ะžะดะธะฝ ะธัั‚ะพั‡ะฝะธะบ", + "International": "ะœะตะถะดัƒะฝะฐั€ะพะดะฝั‹ะต", + "Independent": "ะะตะทะฐะฒะธัะธะผั‹ะต", + "Unattributed": "ะ‘ะตะท ะฐั‚ั€ะธะฑัƒั†ะธะธ", + "Outside party": "ะ’ะฝะตัˆะฝัั ัั‚ะพั€ะพะฝะฐ", + "Ukraineโ€“Russia": "ะฃะบั€ะฐะธะฝะฐโ€“ะ ะพััะธั", + "Israelโ€“Gazaโ€“Lebanon": "ะ˜ะทั€ะฐะธะปัŒโ€“ะ“ะฐะทะฐโ€“ะ›ะธะฒะฐะฝ", + "Iranโ€“Hormuz": "ะ˜ั€ะฐะฝโ€“ะžั€ะผัƒะท", + "Yemenโ€“Red Sea": "ะ™ะตะผะตะฝโ€“ะšั€ะฐัะฝะพะต ะผะพั€ะต", + "Sudan": "ะกัƒะดะฐะฝ", + "Sahel": "ะกะฐั…ะตะปัŒ", + "Elsewhere": "ะ”ั€ัƒะณะธะต ะผะตัั‚ะฐ", + "Strike": "ะฃะดะฐั€", + "Alert": "ะขั€ะตะฒะพะณะฐ", + "Air defence": "ะŸะ’ะž", + "Ground fighting": "ะะฐะทะตะผะฝั‹ะต ะฑะพะธ", + "Casualties": "ะ–ะตั€ั‚ะฒั‹", + "Humanitarian": "ะ“ัƒะผะฐะฝะธั‚ะฐั€ะฝะพะต", + "Disaster": "ะ‘ะตะดัั‚ะฒะธะต", + "Protest": "ะŸั€ะพั‚ะตัั‚", + "Political": "ะŸะพะปะธั‚ะธะบะฐ", + "No physical event": "ะ‘ะตะท ั„ะธะทะธั‡ะตัะบะพะณะพ ัะพะฑั‹ั‚ะธั", + "Minor incident": "ะะตะทะฝะฐั‡ะธั‚ะตะปัŒะฝั‹ะน ะธะฝั†ะธะดะตะฝั‚", + "Significant": "ะ—ะฝะฐั‡ะธั‚ะตะปัŒะฝั‹ะน", + "Major": "ะšั€ัƒะฟะฝั‹ะน", + "age|now": "ัะตะนั‡ะฐั", + "{minutes} min": "{minutes} ะผะธะฝ", + "{hours} h": "{hours} ั‡", + "{count} sources": { + "one": "{count} ะธัั‚ะพั‡ะฝะธะบ", + "few": "{count} ะธัั‚ะพั‡ะฝะธะบะฐ", + "many": "{count} ะธัั‚ะพั‡ะฝะธะบะพะฒ", + "other": "{count} ะธัั‚ะพั‡ะฝะธะบะฐ" + }, + "News feed not configured on this server": "ะ›ะตะฝั‚ะฐ ะฝะพะฒะพัั‚ะตะน ะฝะต ะฝะฐัั‚ั€ะพะตะฝะฐ ะฝะฐ ัั‚ะพะผ ัะตั€ะฒะตั€ะต", + "Malformed news hotspots response": "ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะณะพั€ัั‡ะธั… ั‚ะพั‡ะตะบ ะฝะพะฒะพัั‚ะตะน", + "Malformed news feed response": "ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะปะตะฝั‚ั‹ ะฝะพะฒะพัั‚ะตะน", "Flying to Austin, TX...": "ะŸะตั€ะตะปั‘ั‚ ะฒ ะžัั‚ะธะฝ, ะขะตั…ะฐั...", "Restoring shared view...": "ะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะพะฑั‰ะตะณะพ ะฒะธะดะฐ...", "Initializing systems...": "ะ˜ะฝะธั†ะธะฐะปะธะทะฐั†ะธั ัะธัั‚ะตะผ...", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 388ea566d..41f6f1207 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,4 +1,58 @@ { + "News feed unavailable": "ะกั‚ั€ั–ั‡ะบะฐ ะฝะพะฒะธะฝ ะฝะตะดะพัั‚ัƒะฟะฝะฐ", + "Disputed: {name}": "ะกะฟั–ั€ะฝะฐ ั‚ะตั€ะธั‚ะพั€ั–ั: {name}", + "Administered by {holder} ยท claimed by {claimant}": "ะŸั–ะด ัƒะฟั€ะฐะฒะปั–ะฝะฝัะผ: {holder} ยท ะฟั€ะตั‚ะตะฝะดัƒั”: {claimant}", + "Self-administered ยท claimed by {claimant}": "ะกะฐะผะพะฒั€ัะดะฝะฐ ยท ะฟั€ะตั‚ะตะฝะดัƒั”: {claimant}", + "UN-patrolled zone": "ะ—ะพะฝะฐ ะฟะฐั‚ั€ัƒะปัŽะฒะฐะฝะฝั ะžะžะ", + "Claimed by neither neighbour": "ะะต ะฟั€ะตั‚ะตะฝะดัƒั” ะถะพะดะตะฝ ััƒัั–ะด", + "Video": "ะ’ั–ะดะตะพ", + "Photos": "ะคะพั‚ะพ", + "Claimed by {who}": "ะ—ะฐัะฒะฐ: {who}", + "Claimed": "ะ—ะฐัะฒะฐ", + "Not yet confirmed by others": "ะŸะพะบะธ ะฝะต ะฟั–ะดั‚ะฒะตั€ะดะถะตะฝะพ ั–ะฝัˆะธะผะธ", + "Reported by {side} sources": "ะŸะพะฒั–ะดะพะผะปััŽั‚ัŒ ะดะถะตั€ะตะปะฐ {side}", + "Reported by {sides}": "ะŸะพะฒั–ะดะพะผะปััŽั‚ัŒ: {sides}", + "{side} sources": "ะดะถะตั€ะตะปะฐ {side}", + "Official": "ะžั„ั–ั†ั–ะนะฝะพ", + "Reported by both sides": "ะŸะพะฒั–ะดะพะผะปััŽั‚ัŒ ะพะฑะธะดะฒั– ัั‚ะพั€ะพะฝะธ", + "Independently reported": "ะŸั–ะดั‚ะฒะตั€ะดะถะตะฝะพ ะฝะตะทะฐะปะตะถะฝะธะผะธ ะดะถะตั€ะตะปะฐะผะธ", + "Single source": "ะžะดะฝะต ะดะถะตั€ะตะปะพ", + "International": "ะœั–ะถะฝะฐั€ะพะดะฝั–", + "Independent": "ะะตะทะฐะปะตะถะฝั–", + "Unattributed": "ะ‘ะตะท ะฐั‚ั€ะธะฑัƒั†ั–ั—", + "Outside party": "ะ—ะพะฒะฝั–ัˆะฝั ัั‚ะพั€ะพะฝะฐ", + "Ukraineโ€“Russia": "ะฃะบั€ะฐั—ะฝะฐโ€“ะ ะพัั–ั", + "Israelโ€“Gazaโ€“Lebanon": "ะ†ะทั€ะฐั—ะปัŒโ€“ะ“ะฐะทะฐโ€“ะ›ั–ะฒะฐะฝ", + "Iranโ€“Hormuz": "ะ†ั€ะฐะฝโ€“ะžั€ะผัƒะท", + "Yemenโ€“Red Sea": "ะ„ะผะตะฝโ€“ะงะตั€ะฒะพะฝะต ะผะพั€ะต", + "Sudan": "ะกัƒะดะฐะฝ", + "Sahel": "ะกะฐั…ะตะปัŒ", + "Elsewhere": "ะ†ะฝัˆั– ะผั–ัั†ั", + "Strike": "ะฃะดะฐั€", + "Alert": "ะขั€ะธะฒะพะณะฐ", + "Air defence": "ะŸะŸะž", + "Ground fighting": "ะะฐะทะตะผะฝั– ะฑะพั—", + "Casualties": "ะ–ะตั€ั‚ะฒะธ", + "Humanitarian": "ะ“ัƒะผะฐะฝั–ั‚ะฐั€ะฝะต", + "Disaster": "ะ›ะธั…ะพ", + "Protest": "ะŸั€ะพั‚ะตัั‚", + "Political": "ะŸะพะปั–ั‚ะธะบะฐ", + "No physical event": "ะ‘ะตะท ั„ั–ะทะธั‡ะฝะพั— ะฟะพะดั–ั—", + "Minor incident": "ะะตะทะฝะฐั‡ะฝะธะน ั–ะฝั†ะธะดะตะฝั‚", + "Significant": "ะ—ะฝะฐั‡ะฝะธะน", + "Major": "ะ’ะตะปะธะบะธะน", + "age|now": "ะทะฐั€ะฐะท", + "{minutes} min": "{minutes} ั…ะฒ", + "{hours} h": "{hours} ะณะพะด", + "{count} sources": { + "one": "{count} ะดะถะตั€ะตะปะพ", + "few": "{count} ะดะถะตั€ะตะปะฐ", + "many": "{count} ะดะถะตั€ะตะป", + "other": "{count} ะดะถะตั€ะตะปะฐ" + }, + "News feed not configured on this server": "ะกั‚ั€ั–ั‡ะบัƒ ะฝะพะฒะธะฝ ะฝะต ะฝะฐะปะฐัˆั‚ะพะฒะฐะฝะพ ะฝะฐ ั†ัŒะพะผัƒ ัะตั€ะฒะตั€ั–", + "Malformed news hotspots response": "ะะตะบะพั€ะตะบั‚ะฝะฐ ะฒั–ะดะฟะพะฒั–ะดัŒ ะณะฐั€ัั‡ะธั… ั‚ะพั‡ะพะบ ะฝะพะฒะธะฝ", + "Malformed news feed response": "ะะตะบะพั€ะตะบั‚ะฝะฐ ะฒั–ะดะฟะพะฒั–ะดัŒ ัั‚ั€ั–ั‡ะบะธ ะฝะพะฒะธะฝ", "Pick a shape, then click the map.": "ะ’ะธะฑะตั€ั–ั‚ัŒ ั„ั–ะณัƒั€ัƒ ะน ะบะปะฐั†ะฝั–ั‚ัŒ ะฝะฐ ะบะฐั€ั‚ั–.", "Enter to place the pin, Esc to cancel.": "Enter โ€” ะฟะพัั‚ะฐะฒะธั‚ะธ ะผั–ั‚ะบัƒ, Esc โ€” ัะบะฐััƒะฒะฐั‚ะธ.", "Click where the pin goes.": "ะšะปะฐั†ะฝั–ั‚ัŒ ั‚ะฐะผ, ะดะต ะผะฐั” ะฑัƒั‚ะธ ะผั–ั‚ะบะฐ.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 912582289..30a4d8807 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "ๆ–ฐ้—ปๆบไธๅฏ็”จ", + "Disputed: {name}": "ไบ‰่ฎฎๅœฐๅŒบ๏ผš{name}", + "Administered by {holder} ยท claimed by {claimant}": "็”ฑ{holder}็ฎก่พ– ยท {claimant}ไธปๅผ ไธปๆƒ", + "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ไธปๅผ ไธปๆƒ", + "UN-patrolled zone": "่”ๅˆๅ›ฝๅทก้€ปๅŒบ", + "Claimed by neither neighbour": "ไธคไธช้‚ปๅ›ฝๅ‡ๆœชไธปๅผ ", + "Video": "่ง†้ข‘", + "Photos": "็…ง็‰‡", + "Claimed by {who}": "{who}ๅฃฐ็งฐ", + "Claimed": "ๅฃฐ็งฐ", + "Not yet confirmed by others": "ๅฐšๆœช่Žทไป–ๆ–น่ฏๅฎž", + "Reported by {side} sources": "{side}ๆ–นๆฅๆบๆŠฅ้“", + "Reported by {sides}": "ๆŠฅ้“ๆฅๆบ๏ผš{sides}", + "{side} sources": "{side}ๆ–นๆฅๆบ", + "Official": "ๅฎ˜ๆ–น", + "Reported by both sides": "ๅŒๆ–นๅ‡ๆœ‰ๆŠฅ้“", + "Independently reported": "ๆœ‰็‹ฌ็ซ‹ๆฅๆบๆŠฅ้“", + "Single source": "ๅ•ไธ€ๆฅๆบ", + "International": "ๅ›ฝ้™…", + "Independent": "็‹ฌ็ซ‹", + "Unattributed": "ๆœชๆณจๆ˜Žๆฅๆบ", + "Outside party": "ๅค–้ƒจๆ–น", + "Ukraineโ€“Russia": "ไนŒๅ…‹ๅ…ฐโ€“ไฟ„็ฝ—ๆ–ฏ", + "Israelโ€“Gazaโ€“Lebanon": "ไปฅ่‰ฒๅˆ—โ€“ๅŠ ๆฒ™โ€“้ปŽๅทดๅซฉ", + "Iranโ€“Hormuz": "ไผŠๆœ—โ€“้œๅฐ”ๆœจๅ…น", + "Yemenโ€“Red Sea": "ไนŸ้—จโ€“็บขๆตท", + "Sudan": "่‹ไธน", + "Sahel": "่จ่ตซๅ‹’", + "Elsewhere": "ๅ…ถไป–ๅœฐๅŒบ", + "Strike": "ๆ‰“ๅ‡ป", + "Alert": "่ญฆๆŠฅ", + "Air defence": "้˜ฒ็ฉบ", + "Ground fighting": "ๅœฐ้ขๆˆ˜ๆ–—", + "Casualties": "ไผคไบก", + "Humanitarian": "ไบบ้“", + "Disaster": "็พๅฎณ", + "Protest": "ๆŠ—่ฎฎ", + "Political": "ๆ”ฟๆฒป", + "No physical event": "ๆ— ๅฎž้™…ไบ‹ไปถ", + "Minor incident": "่ฝปๅพฎไบ‹ไปถ", + "Significant": "้‡ๅคง", + "Major": "ไธฅ้‡", + "age|now": "ๅˆšๅˆš", + "{minutes} min": "{minutes} ๅˆ†้’Ÿ", + "{hours} h": "{hours} ๅฐๆ—ถ", + "{count} sources": { + "other": "{count} ไธชๆฅๆบ" + }, + "News feed not configured on this server": "ๆญคๆœๅŠกๅ™จๆœช้…็ฝฎๆ–ฐ้—ปๆบ", + "Malformed news hotspots response": "ๆ–ฐ้—ป็ƒญ็‚นๅ“ๅบ”ๆ ผๅผ้”™่ฏฏ", + "Malformed news feed response": "ๆ–ฐ้—ปๆบๅ“ๅบ”ๆ ผๅผ้”™่ฏฏ", "Pick a shape, then click the map.": "ๅ…ˆ้€‰ๆ‹ฉๅฝข็Šถ๏ผŒๅ†ๅœจๅœฐๅ›พไธŠ็‚นๅ‡ปใ€‚", "Enter to place the pin, Esc to cancel.": "ๆŒ‰ Enter ๆ”พ็ฝฎๅ›พ้’‰๏ผŒๆŒ‰ Esc ๅ–ๆถˆใ€‚", "Click where the pin goes.": "็‚นๅ‡ปๅ›พ้’‰็š„ๆ”พ็ฝฎไฝ็ฝฎใ€‚", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 628c71284..e29d0fff2 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,4 +1,55 @@ { + "News feed unavailable": "ๆ–ฐ่žไพ†ๆบ็„กๆณ•ไฝฟ็”จ", + "Disputed: {name}": "็ˆญ่ญฐๅœฐๅ€๏ผš{name}", + "Administered by {holder} ยท claimed by {claimant}": "็”ฑ{holder}็ฎก่ฝ„ ยท {claimant}ไธปๅผตไธปๆฌŠ", + "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ไธปๅผตไธปๆฌŠ", + "UN-patrolled zone": "่ฏๅˆๅœ‹ๅทก้‚ๅ€", + "Claimed by neither neighbour": "ๅ…ฉๅ€‹้„ฐๅœ‹ๅ‡ๆœชไธปๅผต", + "Video": "ๅฝฑ็‰‡", + "Photos": "็…ง็‰‡", + "Claimed by {who}": "{who}่ฒ็จฑ", + "Claimed": "่ฒ็จฑ", + "Not yet confirmed by others": "ๅฐšๆœช็ฒไป–ๆ–น่ญ‰ๅฏฆ", + "Reported by {side} sources": "{side}ๆ–นไพ†ๆบๅ ฑๅฐŽ", + "Reported by {sides}": "ๅ ฑๅฐŽไพ†ๆบ๏ผš{sides}", + "{side} sources": "{side}ๆ–นไพ†ๆบ", + "Official": "ๅฎ˜ๆ–น", + "Reported by both sides": "้›™ๆ–นๅ‡ๆœ‰ๅ ฑๅฐŽ", + "Independently reported": "ๆœ‰็จ็ซ‹ไพ†ๆบๅ ฑๅฐŽ", + "Single source": "ๅ–ฎไธ€ไพ†ๆบ", + "International": "ๅœ‹้š›", + "Independent": "็จ็ซ‹", + "Unattributed": "ๆœช่จปๆ˜Žไพ†ๆบ", + "Outside party": "ๅค–้ƒจๆ–น", + "Ukraineโ€“Russia": "็ƒๅ…‹่˜ญโ€“ไฟ„็พ…ๆ–ฏ", + "Israelโ€“Gazaโ€“Lebanon": "ไปฅ่‰ฒๅˆ—โ€“ๅŠ ่–ฉโ€“้ปŽๅทดๅซฉ", + "Iranโ€“Hormuz": "ไผŠๆœ—โ€“่ท่Žซ่Œฒ", + "Yemenโ€“Red Sea": "่‘‰้–€โ€“็ด…ๆตท", + "Sudan": "่˜‡ไธน", + "Sahel": "่–ฉ่ตซ็ˆพ", + "Elsewhere": "ๅ…ถไป–ๅœฐๅ€", + "Strike": "ๆ‰“ๆ“Š", + "Alert": "่ญฆๅ ฑ", + "Air defence": "้˜ฒ็ฉบ", + "Ground fighting": "ๅœฐ้ขๆˆฐ้ฌฅ", + "Casualties": "ๅ‚ทไบก", + "Humanitarian": "ไบบ้“", + "Disaster": "็ฝๅฎณ", + "Protest": "ๆŠ—่ญฐ", + "Political": "ๆ”ฟๆฒป", + "No physical event": "็„กๅฏฆ้š›ไบ‹ไปถ", + "Minor incident": "่ผ•ๅพฎไบ‹ไปถ", + "Significant": "้‡ๅคง", + "Major": "ๅšด้‡", + "age|now": "ๅ‰›ๅ‰›", + "{minutes} min": "{minutes} ๅˆ†้˜", + "{hours} h": "{hours} ๅฐๆ™‚", + "{count} sources": { + "other": "{count} ๅ€‹ไพ†ๆบ" + }, + "News feed not configured on this server": "ๆญคไผบๆœๅ™จๆœช่จญๅฎšๆ–ฐ่žไพ†ๆบ", + "Malformed news hotspots response": "ๆ–ฐ่ž็†ฑ้ปžๅ›žๆ‡‰ๆ ผๅผ้Œฏ่ชค", + "Malformed news feed response": "ๆ–ฐ่žไพ†ๆบๅ›žๆ‡‰ๆ ผๅผ้Œฏ่ชค", "Pick a shape, then click the map.": "ๅ…ˆ้ธๆ“‡ๅฝข็‹€๏ผŒๅ†ๅœจๅœฐๅœ–ไธŠ้ปžๆ“Šใ€‚", "Enter to place the pin, Esc to cancel.": "ๆŒ‰ Enter ๆ”พ็ฝฎๅœ–้‡˜๏ผŒๆŒ‰ Esc ๅ–ๆถˆใ€‚", "Click where the pin goes.": "้ปžๆ“Šๅœ–้‡˜็š„ๆ”พ็ฝฎไฝ็ฝฎใ€‚", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 7edaad647..9cc9a667d 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -85,6 +85,7 @@ "House", "Hybrid", "Indie", + "International", "Ironbow", "Jazz", "LIVE", @@ -129,6 +130,7 @@ "Pin", "Plan", "Pop", + "Protest", "Punk", "RADAR ยท {units}", "RADIO", @@ -152,6 +154,7 @@ "STD", "SW", "SYNC", + "Sahel", "San Francisco", "Scanlines", "Scope", @@ -160,6 +163,7 @@ "Soul", "Station", "Station {id}", + "Sudan", "T minus {seconds}", "TAGS ยท {tags}", "TANKER", @@ -171,6 +175,7 @@ "Turboprop", "UPSTREAM", "Upstream HTTP {status}", + "Video", "Vignette", "WHOT/BHOT", "WIND", @@ -295,6 +300,7 @@ "SRC {value}", "SSE", "STD", + "Sahel", "San Francisco", "Sector", "Sonar", @@ -325,6 +331,8 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -446,6 +454,7 @@ "PROPAGATION", "Paris", "Pause", + "Photos", "Plan", "Pop", "Punk", @@ -466,6 +475,7 @@ "STANDARD", "STATION", "STD", + "Sahel", "San Francisco", "Satellites", "Saturation", @@ -509,6 +519,9 @@ "{count} MSGS", "{count} SOURCES", "{count} messages", + "{count} sources", + "{hours} h", + "{minutes} min", "{n} cyclones", "{n} satellites", "{rate} msg/s", @@ -582,6 +595,7 @@ "Hip-Hop", "House", "Indie", + "Iranโ€“Hormuz", "Ironbow", "Jazz", "KTS", @@ -636,11 +650,13 @@ "STD", "STOP", "SYNC", + "Sahel", "San Francisco", "Scope", "Sentinel-2", "Sonar", "Soul", + "Sudan", "TEMP", "TIMEOUT", "Talk", @@ -657,6 +673,7 @@ "VIEWSHED ON", "VOLUME", "VS", + "Video", "WHOT/BHOT", "Washington DC", "World", @@ -681,6 +698,8 @@ "{count} MSGS", "{count} feeds live", "{count} scenes", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{scene} / {pack}: {status}. File: {path}. {attribution} ยท {license}", "{source} HTTP {status}", @@ -840,6 +859,7 @@ "SSE", "STATUS", "STATUS ยท ", + "Sahel", "Sentinel-2", "Sentinel-2 via HLS", "Sonar", @@ -870,6 +890,8 @@ "mph", "nominal", "view band|GLOBAL", + "{hours} h", + "{minutes} min", "{rate} msg/s", "{type} SPD: {speed} HDG: {heading}", "{value} KM", diff --git a/src/layers/news/cards.js b/src/layers/news/cards.js new file mode 100644 index 000000000..75b9932eb --- /dev/null +++ b/src/layers/news/cards.js @@ -0,0 +1,206 @@ +// Event cards on the globe: a picture, the headline in two lines, and "age ยท +// sources ยท sides", with the severity colour down the left edge and the +// event's marker as a badge on the picture. Painted on a canvas and handed to +// the world overlay as a thumbnail entry, so the overlay places them, keeps +// them apart and draws their leader to the marker, as it does CCTV cards. +// +// The picture is the publisher's own (hotlinked, never copied, credited on +// the card); until it loads, or without one, the card shows a tile in the +// severity colour with the category icon. + +import { WORLD_OVERLAY_STYLE } from '../../overlays/worldOverlayTokens.js'; +import { NEWS_SEVERITY } from './model.js'; +import { paintMarker } from './sprites.js'; + +/** Card size in CSS pixels. */ +export const CARD_W = 168; +export const CARD_H = 136; +const PAD = 4; +const PIC_H = 84; +const EDGE = 3; +const SCALE = 2; // canvas pixels per CSS pixel +const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif'; +const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace'; +const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif'; +const MAX_CARDS = 200; + +const pictures = new Map(); // url โ†’ {image, state: 'loading'|'ok'|'failed', waiters} +const cards = new Map(); // key โ†’ canvas + +function picture(url, onLoad) { + let entry = pictures.get(url); + if (!entry) { + const image = new Image(); + // No referrer: the publisher sees a plain image request, as from a link preview. + image.referrerPolicy = 'no-referrer'; + image.decoding = 'async'; + entry = { image, state: 'loading', waiters: new Set() }; + pictures.set(url, entry); + image.onload = () => { + entry.state = 'ok'; + for (const done of entry.waiters) done(); + entry.waiters.clear(); + }; + image.onerror = () => { + entry.state = 'failed'; + entry.waiters.clear(); + }; + image.src = url; + } + if (entry.state === 'loading' && onLoad) entry.waiters.add(onLoad); + return entry; +} + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +/** `text` in at most `lines` lines of `width`, the last cut with an ellipsis. */ +export function wrapLines(ctx, text, width, lines) { + const words = String(text || '') + .split(/\s+/) + .filter(Boolean); + const out = []; + let line = ''; + for (let i = 0; i < words.length; i++) { + const next = line ? `${line} ${words[i]}` : words[i]; + if (ctx.measureText(next).width <= width || !line) { + line = next; + continue; + } + out.push(line); + line = words[i]; + if (out.length === lines - 1) { + line = words.slice(i).join(' '); + break; + } + } + if (line) out.push(line); + const last = out.length - 1; + if (last >= 0 && ctx.measureText(out[last]).width > width) { + let cut = out[last]; + while (cut && ctx.measureText(`${cut}โ€ฆ`).width > width) + cut = cut.slice(0, -1); + out[last] = `${cut.trimEnd()}โ€ฆ`; + } + return out.slice(0, lines); +} + +/** Cover-fit an image into a box. */ +function drawCover(ctx, image, x, y, w, h) { + const iw = image.naturalWidth || image.width; + const ih = image.naturalHeight || image.height; + if (!iw || !ih) return; + const s = Math.max(w / iw, h / ih); + const sw = w / s; + const sh = h / s; + ctx.drawImage(image, (iw - sw) / 2, (ih - sh) / 2, sw, sh, x, y, w, h); +} + +function paint(canvas, view, look, pic) { + const ctx = canvas.getContext('2d'); + ctx.setTransform(SCALE, 0, 0, SCALE, 0, 0); + ctx.clearRect(0, 0, CARD_W, CARD_H); + const color = (NEWS_SEVERITY[look.severity] || NEWS_SEVERITY[0]).color; + + roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius); + ctx.fillStyle = WORLD_OVERLAY_STYLE.selectedBackground; + ctx.fill(); + ctx.strokeStyle = WORLD_OVERLAY_STYLE.border; + ctx.lineWidth = 1; + ctx.stroke(); + // Severity down the left edge, where Breaking marks a Live report. + ctx.save(); + roundRect(ctx, 0.5, 0.5, CARD_W - 1, CARD_H - 1, WORLD_OVERLAY_STYLE.radius); + ctx.clip(); + ctx.fillStyle = color; + ctx.fillRect(0, 0, EDGE, CARD_H); + ctx.restore(); + + const px = PAD + EDGE; + const pw = CARD_W - px - PAD; + ctx.save(); + roundRect(ctx, px, PAD, pw, PIC_H, 3); + ctx.clip(); + if (pic?.state === 'ok') { + try { + drawCover(ctx, pic.image, px, PAD, pw, PIC_H); + } catch { + pic.state = 'failed'; + } + } + if (pic?.state !== 'ok') { + // No picture: a tile in the severity colour with the category icon. + const g = ctx.createLinearGradient(px, PAD, px + pw, PAD + PIC_H); + g.addColorStop(0, WORLD_OVERLAY_STYLE.background); + g.addColorStop(1, color); + ctx.fillStyle = g; + ctx.globalAlpha = 0.55; + ctx.fillRect(px, PAD, pw, PIC_H); + ctx.globalAlpha = 1; + paintMarker(ctx, look, px + pw / 2, PAD + PIC_H / 2, 34); + } + ctx.restore(); + // The event's marker as a badge, so the card reads with the map. + if (pic?.state === 'ok') paintMarker(ctx, look, px + 13, PAD + 13, 18); + // The picture's publisher, always named on it. + if (pic?.state === 'ok' && view.credit) { + ctx.font = CREDIT_FONT; + const w = Math.min(pw - 30, ctx.measureText(view.credit).width + 8); + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(px + pw - w, PAD + PIC_H - 12, w, 12); + ctx.fillStyle = WORLD_OVERLAY_STYLE.title; + ctx.textBaseline = 'middle'; + ctx.fillText( + wrapLines(ctx, view.credit, w - 8, 1)[0] || '', + px + pw - w + 4, + PAD + PIC_H - 6, + ); + } + + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = WORLD_OVERLAY_STYLE.title; + ctx.font = TITLE_FONT; + wrapLines(ctx, view.title, pw, 2).forEach((line, i) => + ctx.fillText(line, px, PAD + PIC_H + 14 + i * 13), + ); + ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; + ctx.font = META_FONT; + ctx.fillText(wrapLines(ctx, view.meta, pw, 1)[0] || '', px, CARD_H - PAD - 2); +} + +/** + * The canvas of an event's card. `onChange` runs when its picture arrives and + * the canvas has been repainted (ask the scene for a frame). + * @returns {HTMLCanvasElement|null} null where there is no canvas + */ +export function eventCard(id, view, look, onChange) { + const key = `${id}|${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.title}|${view.meta}`; + if (cards.has(key)) return cards.get(key); + const canvas = globalThis.document?.createElement?.('canvas'); + if ( + !canvas?.getContext?.('2d') || + typeof globalThis.Path2D !== 'function' || + typeof globalThis.Image !== 'function' + ) + return null; + canvas.width = CARD_W * SCALE; + canvas.height = CARD_H * SCALE; + const pic = view.image + ? picture(view.image, () => { + paint(canvas, view, look, pic); + onChange?.(); + }) + : null; + paint(canvas, view, look, pic); + // Old cards go once there are many; the newest keys win. + if (cards.size >= MAX_CARDS) cards.delete(cards.keys().next().value); + cards.set(key, canvas); + return canvas; +} diff --git a/src/layers/news/model.js b/src/layers/news/model.js new file mode 100644 index 000000000..41fce5ab4 --- /dev/null +++ b/src/layers/news/model.js @@ -0,0 +1,643 @@ +// Pure presentation model for the news layer: severity, category glyphs, +// attestation, marker looks and placement, the feed's filters and paging, and +// the event cards the News panel and the globe show. No Cesium, no DOM. + +import { getLocale, msg, t, tn } from '../../i18n/index.js'; + +export const NEWS_LAYER_ID = 'news'; +export const NEWS_OVERLAY_SOURCE_ID = 'news'; +export const NEWS_PICK_PREFIX = 'news:'; +export const NEWS_FEED_PAGE = 60; +export const NEWS_MAP_LIMIT = 500; +/** News is real time only: nothing older than a day is shown anywhere. */ +export const NEWS_WINDOW_MS = 24 * 3_600_000; +// Above this camera height the layer shows one beacon per hotspot; below it +// (or once a hotspot is chosen) the outlines and individual reports. +export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000; +// "In view": reports near the view centre, only once the camera is low enough +// for "here" to mean a region, read again when it settles in a new cell. +export const NEWS_IN_VIEW_MAX_HEIGHT_M = 3_000_000; +export const NEWS_IN_VIEW_RADIUS_KM = 500; +export const NEWS_IN_VIEW_LIMIT = 12; +export const NEWS_SETTLE_MS = 800; +export const NEWS_CELL_DEG = 0.5; +/** A feed card's click flies the camera to this height over the event. */ +export const NEWS_FOCUS_HEIGHT_M = 150_000; +// Minor news (a broad outlet's prices, sport, health) is left out unless asked for. +export const NEWS_RELEVANT = 'core,related'; + +const SOURCE_LABEL = msg('Source'); +const DISPUTED = msg('Disputed: {name}'); +const DISPUTE_TEXT = { + held: msg('Administered by {holder} ยท claimed by {claimant}'), + self: msg('Self-administered ยท claimed by {claimant}'), + un: msg('UN-patrolled zone'), + unclaimed: msg('Claimed by neither neighbour'), +}; +const MEDIA_LABELS = { video: msg('Video'), photo: msg('Photos') }; +const CLAIMED_BY = msg('Claimed by {who}'); +const CLAIMED = msg('Claimed'); +const UNCONFIRMED = msg('Not yet confirmed by others'); +const REPORTED_BY_SIDE = msg('Reported by {side} sources'); +const REPORTED_BY_SIDES = msg('Reported by {sides}'); +const SIDE_SOURCES = msg('{side} sources'); +const OFFICIAL = msg('Official'); +const OFFICIAL_AFFILIATIONS = new Set(['military', 'state']); +const ATTESTATION = { + 'both-sides': msg('Reported by both sides'), + independent: msg('Independently reported'), + single: msg('Single source'), +}; +const NEUTRAL_SIDES = Object.freeze({ + intl: msg('International'), + independent: msg('Independent'), + unknown: msg('Unattributed'), + external: msg('Outside party'), +}); + +/** + * Short chip names for the curated hotspots; the API's own English name is + * the fallback for any other. + */ +export const NEWS_HOTSPOT_LABELS = Object.freeze({ + 'ua-ru-war': msg('Ukraineโ€“Russia'), + 'israel-gaza-lebanon': msg('Israelโ€“Gazaโ€“Lebanon'), + 'iran-hormuz': msg('Iranโ€“Hormuz'), + yemen: msg('Yemenโ€“Red Sea'), + 'sudan-war': msg('Sudan'), + sahel: msg('Sahel'), + elsewhere: msg('Elsewhere'), +}); + +/** A hotspot's chip label in the interface language. */ +export function hotspotLabel(hotspot) { + const key = NEWS_HOTSPOT_LABELS[hotspot?.id]; + if (key) return t(key); + return hotspot?.catchAll ? t(NEWS_HOTSPOT_LABELS.elsewhere) : hotspot?.name; +} + +/** A country's name in the interface language, from its ISO code. */ +export function countryName(code) { + if (!code) return ''; + try { + return ( + new Intl.DisplayNames([getLocale(), 'en'], { type: 'region' }).of(code) || + code + ); + } catch { + return code; + } +} + +/** Label for a disputed area: its name, who administers it, who claims it. */ +export function disputedLabel(area) { + return { + title: t(DISPUTED, { name: area.name }), + detail: t(DISPUTE_TEXT[area.kind] || DISPUTE_TEXT.held, { + holder: countryName(area.heldBy), + claimant: countryName(area.claimedBy), + }), + }; +} + +// A category is told by its icon (a 16-unit SVG path, filled or stroked). On +// the map and in the feed colour is severity (NEWS_SEVERITY), never the type +// of event or a side. +export const NEWS_CATEGORIES = Object.freeze({ + strike: { + label: msg('Strike'), + icon: 'M8 0.8 L9.6 5.2 L14.2 3.6 L11.2 7.6 L15.2 10.6 L10.3 10.6 L8 15.2 L5.7 10.6 L0.8 10.6 L4.8 7.6 L1.8 3.6 L6.4 5.2 Z', + solid: true, + }, + // A threat on its way (a drone's course, an air-raid alert): nothing has + // hit yet. Not a physical event, so never a picture card. + alert: { + label: msg('Alert'), + icon: 'M1.5 8 H11.5 M8 4.5 L11.5 8 L8 11.5 M14.5 3.5 V12.5', + }, + 'air-defence': { + label: msg('Air defence'), + icon: 'M8 1.5 L13.5 3.5 V8 C13.5 11.3 10.8 13.4 8 14.6 C5.2 13.4 2.5 11.3 2.5 8 V3.5 Z M8 4.5 V11', + }, + ground: { + label: msg('Ground fighting'), + icon: 'M3 13 L12 4 M8.5 3.5 H12.5 V7.5 M13 13 L4 4 M3.5 7.5 V3.5 H7.5', + }, + casualties: { + label: msg('Casualties'), + icon: 'M6.2 2 H9.8 V6.2 H14 V9.8 H9.8 V14 H6.2 V9.8 H2 V6.2 H6.2 Z', + solid: true, + }, + humanitarian: { + label: msg('Humanitarian'), + icon: 'M8 2.5 L14.5 13.5 H1.5 Z M8 2.5 V13.5', + }, + infrastructure: { + label: msg('Infrastructure'), + icon: 'M9.2 1 L3.2 9 H7.6 L6.6 15 L12.8 6.8 H8.4 Z', + solid: true, + }, + disaster: { + label: msg('Disaster'), + icon: 'M1.5 6.5 C3.5 4.5 5.5 4.5 7.5 6.5 S11.5 8.5 14.5 6.5 M1.5 10.5 C3.5 8.5 5.5 8.5 7.5 10.5 S11.5 12.5 14.5 10.5', + }, + protest: { + label: msg('Protest'), + icon: 'M2.5 6.2 V9.8 H5 L12.5 13.2 V2.8 L5 6.2 Z', + }, + political: { + label: msg('Political'), + icon: 'M2 14 H14 M2.5 5.5 H13.5 M8 1.8 L14 4.8 H2 Z M4.2 6.5 V12 M8 6.5 V12 M11.8 6.5 V12', + }, + other: { + label: msg('Other'), + icon: 'M8 4.5 A3.5 3.5 0 1 0 8.01 4.5 Z', + solid: true, + }, +}); + +// Severity 0โ€“3. Lightness falls as severity rises, so the ramp still reads +// under colour blindness and in greyscale. +export const NEWS_SEVERITY = Object.freeze([ + { label: msg('No physical event'), color: '#8a9aa8' }, + { label: msg('Minor incident'), color: '#f2d64b' }, + { label: msg('Significant'), color: '#f28c28' }, + { label: msg('Major'), color: '#d7301f' }, +]); +/** The feed's default floor: significant and major events. */ +export const NEWS_DEFAULT_MIN_SEVERITY = 2; +const PHYSICAL = new Set([ + 'strike', + 'air-defence', + 'ground', + 'casualties', + 'infrastructure', + 'disaster', +]); + +export const categoryStyle = (category) => + NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other; +export const categoryLabel = (category) => t(categoryStyle(category).label); +/** Party codes (UA, RU, ILโ€ฆ) are shown as-is; neutral sides are translated. */ +export const sideLabel = (side) => + Object.hasOwn(NEUTRAL_SIDES, side) ? t(NEUTRAL_SIDES[side]) : side; + +/** + * A report's severity, 0โ€“3: the server's (the local model's reading, else + * its rules, raised to its event's), or a fallback from the category. + */ +export function reportSeverity(report) { + const own = Number.isInteger(report?.severity) ? report.severity : null; + const event = Number.isInteger(report?.event?.severity) + ? report.event.severity + : null; + if (own === null && event === null) + return PHYSICAL.has(report?.category) ? 1 : 0; + return Math.max(own ?? 0, event ?? 0); +} +export const severityStyle = (report) => NEWS_SEVERITY[reportSeverity(report)]; +export const severityLabel = (report) => t(severityStyle(report).label); + +/** When a report (or its event) was last heard of, in ms; NaN when unknown. */ +const lastHeard = (report) => + Date.parse(report?.event?.lastAt || report?.publishedAt || ''); + +/** Whether a report is from the last 24 hours (the only news shown). */ +export function withinNewsWindow(report, now = Date.now()) { + const at = Date.parse(report?.publishedAt || ''); + return Number.isFinite(at) && now - at <= NEWS_WINDOW_MS; +} + +/** + * How a report or its event is attested, as a chip: `claimed` (someone's + * claim, named where known), `confirmed` (both sides, or an independent + * source), `side` (one party's own press), `single` (one report), or null. + */ +export function attestation(report) { + const event = report?.event; + const status = event?.status || (report?.claim ? 'claimed' : null); + if (status === 'claimed') { + const who = event?.claimedBy || report?.claim?.by; + return { + kind: 'claimed', + label: who ? t(CLAIMED_BY, { who }) : t(CLAIMED), + }; + } + if (status === 'both-sides' || status === 'independent') + return { kind: 'confirmed', label: t(ATTESTATION[status]) }; + if (status === 'side' && event.reportedSide) + return { + kind: 'side', + label: t(REPORTED_BY_SIDE, { side: sideLabel(event.reportedSide) }), + }; + if (status === 'single') + return { kind: 'single', label: t(ATTESTATION.single) }; + return null; +} + +/** + * The attestation chip under a report. An official channel's own post is + * already marked Official: "Claimed by" itself would read as doubt about an + * alert. It says only that nobody else has reported it yet, and nothing for + * an alert, which is the channel's own notice. + */ +function ownClaimChip(report) { + const chip = attestation(report); + if ( + chip?.kind !== 'claimed' || + !OFFICIAL_AFFILIATIONS.has(report.affiliation) + ) + return chip; + const who = report.event?.claimedBy || report.claim?.by; + if (who && who !== report.sourceName && who !== report.publisher) return chip; + return report.category === 'alert' + ? null + : { kind: 'claimed', label: t(UNCONFIRMED) }; +} + +// The map card is a label, not a reader: long headlines are cut. +export const NEWS_CARD_TITLE_MAX = 90; + +/** A headline cut at a word to fit `max` characters. */ +export function shortTitle(title, max = NEWS_CARD_TITLE_MAX) { + const text = String(title || ''); + if (text.length <= max) return text; + const cut = text.slice(0, max - 1); + const word = cut.lastIndexOf(' '); + return `${word > max * 0.6 ? cut.slice(0, word) : cut}โ€ฆ`; +} + +/** The media a post carries ("Video", "Photos"), translated. */ +export function mediaLabels(report) { + return (report?.media || []) + .map((m) => (MEDIA_LABELS[m] ? t(MEDIA_LABELS[m]) : '')) + .filter(Boolean); +} + +/** + * How long ago, in few words: "now" under a minute, "N min" under an hour, + * "N h" under a day. Older is never shown; it reads + * "24 h". + */ +export function ageWords(at, now = Date.now()) { + const minutes = Math.max(0, Math.floor((now - Date.parse(at)) / 60_000)); + if (!Number.isFinite(minutes)) return ''; + if (minutes < 1) return t('now', { context: 'age' }); + if (minutes < 60) return t('{minutes} min', { minutes }); + return t('{hours} h', { hours: Math.min(24, Math.floor(minutes / 60)) }); +} + +/** + * How a report's marker looks: fill = severity, icon = category, rim = + * attestation (`confirmed` solid, `claimed` dashed, else thin), size = + * sources, opacity = age (fading to 45 % over a day). + */ +export function markerStyle(report, now = Date.now()) { + const kind = attestation(report)?.kind; + const at = lastHeard(report); + const hours = Number.isFinite(at) ? Math.max(0, (now - at) / 3_600_000) : 0; + return { + category: Object.hasOwn(NEWS_CATEGORIES, report?.category) + ? report.category + : 'other', + severity: reportSeverity(report), + rim: kind === 'confirmed' || kind === 'claimed' ? kind : 'thin', + size: eventPixelSize(report?.event?.size), + alpha: Math.max(0.45, 1 - (0.55 * Math.min(hours, 24)) / 24), + }; +} + +function hash(text) { + let h = 2166136261; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** + * Trend glyph for a hotspot: โ–ฒ/โ–ผ when the last 24 h differ from the 24 h + * before by more than a fifth, nothing when flat or without a prior day. + */ +export function hotspotTrend(last24h, prev24h) { + if (!(prev24h > 0)) return ''; + if (last24h > prev24h * 1.2) return 'โ–ฒ'; + if (last24h < prev24h * 0.8) return 'โ–ผ'; + return ''; +} + +/** Beacon size in pixels: grows with the square root of daily activity. */ +export const beaconPixelSize = (last24h) => + Math.round(Math.min(26, 10 + Math.sqrt(Math.max(0, last24h)) * 1.4)); + +/** Marker size for an event: grows with the number of distinct sources. */ +export function eventPixelSize(sources = 1) { + return Math.round( + Math.min(30, 16 + 3.5 * Math.log2(Math.max(1, Number(sources) || 1))), + ); +} + +// An event several sources report within the last few hours gets a halo. +export const NEWS_PRESSING_SOURCES = 5; +const PRESSING_MS = 3 * 3_600_000; + +/** Whether an event is fresh and widely reported, or fresh and major. */ +export function isPressing(report, now = Date.now()) { + const event = report?.event; + const fresh = now - lastHeard(report) <= PRESSING_MS; + return Boolean( + fresh && + ((event && event.size >= NEWS_PRESSING_SOURCES) || + reportSeverity(report) === 3), + ); +} + +// The big events stand as picture cards over their marker: several +// sources, and major or pressing. +export const NEWS_CARD_LIMIT = 10; +export const NEWS_CARD_MIN_SOURCES = 2; + +/** Whether an event gets a card on the map: physical events only, not statements. */ +export function isCardEvent(report, now = Date.now()) { + return Boolean( + report?.event?.size >= NEWS_CARD_MIN_SOURCES && + PHYSICAL.has(report.category) && + (reportSeverity(report) === 3 || isPressing(report, now)), + ); +} + +/** The card events among `reports`, most severe, most reported and newest first. */ +export function cardEvents(reports, now = Date.now(), limit = NEWS_CARD_LIMIT) { + return reports + .filter((r) => r.pins?.length && isCardEvent(r, now)) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + b.event.size - a.event.size || + (b.event.lastAt || '').localeCompare(a.event.lastAt || ''), + ) + .slice(0, limit); +} + +/** The distinct sides an event was reported by, lead first, translated. */ +function eventSides(report) { + const sides = (report?.event?.sides || []).map((s) => s.side); + return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))]; +} + +/** "Reported by UA sources ยท RU sources", or '' when no side is known. */ +export function sidesLine(report) { + const sides = eventSides(report).filter((side) => side !== 'unknown'); + if (!sides.length) return ''; + if (sides.length === 1) + return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) }); + return t(REPORTED_BY_SIDES, { + sides: sides + .map((side) => t(SIDE_SOURCES, { side: sideLabel(side) })) + .join(' ยท '), + }); +} + +/** The event's picture for a card: two or more sources, and the publisher named. */ +export function eventPicture(report) { + const event = report?.event; + if (!event?.image || event.size < NEWS_CARD_MIN_SOURCES) return null; + return { url: event.image.url, credit: event.image.publisher || '' }; +} + +/** What an event's picture card on the globe shows. */ +export function cardView(report, now = Date.now()) { + const event = report.event; + const sides = eventSides(report).map(sideLabel); + const picture = eventPicture(report); + return { + title: report.titleEn || report.title, + meta: [ + ageWords(event?.lastAt || report.publishedAt, now), + tn(event?.size || 1, '{count} source', '{count} sources'), + sides.slice(0, 3).join(', ') + (sides.length > 3 ? 'โ€ฆ' : ''), + ] + .filter(Boolean) + .join(' ยท '), + image: picture?.url || null, + credit: picture?.credit || '', + }; +} + +/** "Source: host โ†—" line for a report's card; the card opens the article. */ +export function sourceLinkLabel(url) { + let host = ''; + try { + host = new URL(url).hostname.replace(/^www\./, ''); + } catch { + return ''; + } + return `${t(SOURCE_LABEL)}: ${host} โ†—`; +} + +/** The places a report is pinned at, joined, or null. */ +function placeLine(report) { + return ( + (report.pins?.length ? report.pins : [report.geo]) + .map((p) => p?.place) + .filter(Boolean) + .join(' ยท ') || null + ); +} + +/** + * One event card in the News panel: picture, + * headline, a mono meta line (severity, category, place, sources, age), the + * sides line, then attestation and media chips. Labels and colours are + * resolved here so the panel imports no layer code. + */ +export function feedCardView(report, now = Date.now()) { + const severity = reportSeverity(report); + const style = categoryStyle(report.category); + const size = report.event?.size || 1; + const translated = Boolean(report.titleEn && report.titleEn !== report.title); + return { + id: report.id, + url: report.url, + title: translated ? report.titleEn : report.title, + // The local model's English headline over a post in another language: + // the original stays beside it. + original: translated ? report.title : null, + snippet: report.snippet, + severity, + severityLabel: t(NEWS_SEVERITY[severity].label), + color: NEWS_SEVERITY[severity].color, + icon: style.icon, + solid: Boolean(style.solid), + category: categoryLabel(report.category), + place: placeLine(report), + placedByModel: Boolean(report.geo?.byModel), + mappable: Boolean(report.pins?.length), + sources: tn(size, '{count} source', '{count} sources'), + age: ageWords(report.event?.lastAt || report.publishedAt, now), + publisher: report.publisher, + sides: sidesLine(report), + sourceLink: sourceLinkLabel(report.url), + picture: eventPicture(report), + chips: [ + // An official channel speaking for itself (a ministry, the armed + // forces, a press service), not an outlet that quotes one. + OFFICIAL_AFFILIATIONS.has(report.affiliation) + ? { kind: 'official', label: t(OFFICIAL) } + : null, + ownClaimChip(report), + ...mediaLabels(report).map((label) => ({ kind: 'media', label })), + ].filter(Boolean), + // The event's first report from each other side, under the lead. + others: (report.event?.sides || []).slice(1).map((s) => ({ + side: sideLabel(s.side), + publisher: s.publisher, + title: s.title, + url: s.url, + })), + }; +} + +/** + * Marker position for a report. Many reports name the same city and would + * stack on one centroid, so each is spread on a small deterministic ring + * (about 1.5 km for a place, 6 km for a region, 28 km for a country) to stay + * individually clickable. The spread is display-only; cards show the place. + */ +export function reportMarkerPosition(report, pin = 0) { + const { lat, lon, precision } = report.pins?.[pin] || report.geo; + const h = hash(pin ? `${report.id}~${pin}` : report.id); + const angle = ((h % 3600) / 3600) * 2 * Math.PI; + const radiusDeg = + (precision === 'country' ? 0.25 : precision === 'region' ? 0.055 : 0.014) * + (0.35 + ((h >>> 12) % 65) / 100); + return { + lat: lat + radiusDeg * Math.sin(angle), + lon: lon + (radiusDeg * Math.cos(angle)) / Math.cos((lat * Math.PI) / 180), + }; +} + +/** + * Merge a page into the feed, newest first, one row per report id. + * `mode` 'head' refreshes the top and keeps the older cursor; 'append' adds an + * older page and takes its cursor; 'replace' starts over. + */ +export function mergeFeedPage(feed, page, mode) { + const byId = new Map(); + const items = + mode === 'replace' + ? page.reports + : mode === 'append' + ? [...feed.items, ...page.reports] + : [...page.reports, ...feed.items]; + for (const item of items) if (!byId.has(item.id)) byId.set(item.id, item); + return { + items: [...byId.values()].sort((a, b) => + b.publishedAt.localeCompare(a.publishedAt), + ), + nextBefore: + mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore, + }; +} + +/** Normalised filters; anything unknown falls back to its default. */ +export function normalizeFilters(next = {}, hotspots = [], previous = {}) { + const merged = { ...previous, ...next }; + const hotspot = hotspots.some((h) => h.id === merged.hotspot) + ? merged.hotspot + : 'all'; + const category = Object.hasOwn(NEWS_CATEGORIES, merged.category) + ? merged.category + : 'all'; + const minSeverity = + Number.isInteger(merged.minSeverity) && + merged.minSeverity >= 0 && + merged.minSeverity <= 3 + ? merged.minSeverity + : NEWS_DEFAULT_MIN_SEVERITY; + return { + hotspot, + category, + minSeverity, + includeMinor: merged.includeMinor === true, + // Event cards on the map; off leaves only the clicked event's card. + cards: merged.cards !== false, + }; +} + +/** Whether a report passes the filters the server does not apply. */ +export function passesFilters(report, filters, now = Date.now()) { + return ( + withinNewsWindow(report, now) && + reportSeverity(report) >= filters.minSeverity + ); +} + +/** Whether a report belongs on the map under the current filters. */ +export function visibleOnMap(report, filters, now = Date.now()) { + return ( + (filters.hotspot === 'all' || + (report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) && + (filters.category === 'all' || report.category === filters.category) && + passesFilters(report, filters, now) + ); +} + +/** API query for the feed under the current filters: always the last 24 h. */ +export function feedQueryFor(filters, extra = {}) { + return { + events: true, + today: true, + relevance: filters.includeMinor ? '' : NEWS_RELEVANT, + hotspot: filters.hotspot, + category: filters.category === 'all' ? '' : filters.category, + limit: NEWS_FEED_PAGE, + ...extra, + }; +} + +/** The half-degree cell a point lies in, as a key. */ +export function viewCell({ lat, lon }) { + return `${Math.floor(lat / NEWS_CELL_DEG)}:${Math.floor(lon / NEWS_CELL_DEG)}`; +} + +/** Great-circle distance in kilometres. */ +export function distanceKm(a, b) { + const rad = Math.PI / 180; + const dLat = (b.lat - a.lat) * rad; + const dLon = (b.lon - a.lon) * rad; + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLon / 2) ** 2; + return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s))); +} + +/** + * The reports near `centre` (any pin within NEWS_IN_VIEW_RADIUS_KM) that pass + * the filters, most severe and newest first. + */ +export function reportsInView( + reports, + centre, + filters, + now = Date.now(), + limit = NEWS_IN_VIEW_LIMIT, +) { + if (!centre) return []; + return reports + .filter( + (r) => + passesFilters(r, filters, now) && + (filters.category === 'all' || r.category === filters.category) && + r.pins?.some( + (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM, + ), + ) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + b.publishedAt.localeCompare(a.publishedAt), + ) + .slice(0, limit); +} diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs new file mode 100644 index 000000000..39492cc41 --- /dev/null +++ b/src/layers/news/model.test.mjs @@ -0,0 +1,444 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeFeedPayload, + normalizeHotspotsPayload, + normalizeOutlinePayload, +} from './records.js'; +import { + NEWS_IN_VIEW_RADIUS_KM, + ageWords, + beaconPixelSize, + cardEvents, + cardView, + distanceKm, + eventPixelSize, + feedCardView, + feedQueryFor, + hotspotLabel, + hotspotTrend, + isPressing, + markerStyle, + mergeFeedPage, + normalizeFilters, + passesFilters, + reportMarkerPosition, + reportSeverity, + reportsInView, + sidesLine, + sourceLinkLabel, + viewCell, + visibleOnMap, + withinNewsWindow, +} from './model.js'; +import { newsFeedQuery } from './source.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const report = (id, publishedAt, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://example.com/${id}`, + publishedAt, + publisher: 'Example', + side: 'UA', + category: 'strike', + hotspotIds: ['ua'], + geo: { + lat: 50, + lon: 36, + place: 'Kharkiv', + precision: 'city', + mappable: true, + }, + pins: [ + { lat: 50, lon: 36, place: 'Kharkiv', precision: 'city', mappable: true }, + ], + ...extra, +}); +const event = (extra = {}) => ({ + status: 'both-sides', + size: 6, + severity: 3, + lastAt: '2026-10-28T09:30:00Z', + sides: [ + { side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' }, + { side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' }, + ], + image: { url: 'https://a.test/p.jpg', publisher: 'A' }, + ...extra, +}); + +test('payloads are validated record by record; empty and malformed payloads', () => { + const hotspots = normalizeHotspotsPayload({ + hotspots: [ + { + id: 'ua', + name: 'UA', + bbox: [22, 44, 42, 56], + parties: ['UA', 'RU', ''], + counts: { last24h: 3 }, + }, + { id: 'bad', name: 'Bad', bbox: [42, 44, 22, 56] }, + { id: '../x', name: 'Bad id', bbox: [22, 44, 42, 56] }, + ], + ingest: { lastRunAt: '2026-10-28T10:00:00Z' }, + }); + assert.deepEqual( + hotspots.hotspots.map((h) => [h.id, h.parties, h.last24h]), + [['ua', ['UA', 'RU'], 3]], + ); + assert.equal(hotspots.lastRunAt, '2026-10-28T10:00:00.000Z'); + assert.equal(normalizeHotspotsPayload({}), null); + assert.equal(normalizeHotspotsPayload(null), null); + assert.deepEqual(normalizeHotspotsPayload({ hotspots: [] }).hotspots, []); + + const feed = normalizeFeedPayload({ + reports: [ + { + id: 'r_1', + title: ' Strike on Kharkiv ', + url: 'https://x.test/a', + publishedAt: '2026-10-28T09:00:00Z', + geo: { lat: 50, lon: 36, mappable: true }, + media: ['video', 'video', 'audio'], + event: { size: 3, image: { url: 'http://x.test/p.jpg' } }, + }, + { + id: 'r_2', + title: 'Bad link', + url: 'javascript:alert(1)', + publishedAt: '2026-10-28T09:00:00Z', + }, + { + id: 'r_3', + title: 'Bad geo', + url: 'https://user:pw@x.test/c', + publishedAt: '2026-10-28T09:00:00Z', + }, + { + id: 'r_4', + title: 'Off the globe', + url: 'https://x.test/d', + publishedAt: '2026-10-28T09:00:00Z', + geo: { lat: 200, lon: 36 }, + }, + ], + nextBefore: '2026-10-28T08:00:00Z', + }); + assert.deepEqual( + feed.reports.map((r) => [r.id, r.title, Boolean(r.geo), r.pins.length]), + [ + ['r_1', 'Strike on Kharkiv', true, 1], + ['r_4', 'Off the globe', false, 0], + ], + ); + assert.deepEqual(feed.reports[0].media, ['video']); + // A picture must be HTTPS on the publisher's site. + assert.equal(feed.reports[0].event.image, null); + assert.equal(feed.nextBefore, '2026-10-28T08:00:00.000Z'); + assert.equal(normalizeFeedPayload({ reports: 'x' }), null); + assert.deepEqual(normalizeFeedPayload({ reports: [] }), { + reports: [], + nextBefore: null, + }); +}); + +test('outlines keep valid rings, borders and named disputed areas', () => { + const ring = [ + [30, 45], + [31, 45], + [31, 46], + ]; + const outline = normalizeOutlinePayload({ + polygons: [{ outer: ring, holes: [[[0, 0]]] }, { outer: [[1, 2]] }], + borders: [ring, 'x'], + disputed: [ + { + name: 'Crimea', + kind: 'held', + heldBy: 'RU', + claimedBy: 'UA', + rings: [ring], + }, + { name: 'No rings', rings: [] }, + { name: 'Odd kind', kind: 'zzz', heldBy: 'nope', rings: [ring] }, + ], + }); + assert.equal(outline.polygons.length, 1); + assert.deepEqual(outline.polygons[0].holes, []); + assert.equal(outline.borders.length, 1); + assert.deepEqual( + outline.disputed.map((d) => [d.name, d.kind, d.heldBy]), + [ + ['Crimea', 'held', 'RU'], + ['Odd kind', 'held', null], + ], + ); + assert.deepEqual(normalizeOutlinePayload(null), { + polygons: [], + borders: [], + disputed: [], + }); +}); + +test('feed pages merge newest first without duplicates', () => { + const a = report('a', '2026-10-28T10:00:00Z'); + const b = report('b', '2026-10-28T09:00:00Z'); + const c = report('c', '2026-10-28T08:00:00Z'); + let feed = mergeFeedPage( + { items: [] }, + { reports: [a, b], nextBefore: 'B' }, + 'replace', + ); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['a', 'b'], 'B'], + ); + feed = mergeFeedPage(feed, { reports: [b, c], nextBefore: null }, 'append'); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['a', 'b', 'c'], null], + ); + const newer = report('n', '2026-10-28T11:00:00Z'); + feed = mergeFeedPage( + { ...feed, nextBefore: 'C' }, + { reports: [newer, a], nextBefore: 'X' }, + 'head', + ); + assert.deepEqual( + [feed.items.map((r) => r.id), feed.nextBefore], + [['n', 'a', 'b', 'c'], 'C'], + ); + assert.deepEqual( + mergeFeedPage({ items: [] }, { reports: [], nextBefore: null }, 'replace'), + { items: [], nextBefore: null }, + ); +}); + +test('filters default to significant news and map to a last-24-hours query', () => { + const hotspots = [{ id: 'ua' }, { id: 'me' }]; + const filters = normalizeFilters( + { hotspot: 'ua', category: 'nope', minSeverity: 9 }, + hotspots, + ); + assert.deepEqual(filters, { + hotspot: 'ua', + category: 'all', + minSeverity: 2, + includeMinor: false, + cards: true, + }); + assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false); + assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0); + assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all'); + assert.equal( + newsFeedQuery( + feedQueryFor(filters, { before: '2026-10-28T08:00:00.000Z' }), + ), + 'hotspot=ua&before=2026-10-28T08%3A00%3A00.000Z&limit=60&relevance=core%2Crelated&today=1&events=1', + ); + // "Include minor news" drops the relevance filter. + assert.doesNotMatch( + newsFeedQuery(feedQueryFor({ ...filters, includeMinor: true })), + /relevance/, + ); +}); + +test('only the last 24 hours pass, and the severity floor applies', () => { + const filters = normalizeFilters(); + const fresh = report('f', '2026-10-28T09:00:00Z', { severity: 2 }); + const minor = report('m', '2026-10-28T09:00:00Z', { severity: 1 }); + const old = report('o', '2026-10-27T09:00:00Z', { severity: 3 }); + assert.equal(withinNewsWindow(fresh, NOW), true); + assert.equal(withinNewsWindow(old, NOW), false); + assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false); + assert.deepEqual( + [fresh, minor, old] + .filter((r) => passesFilters(r, filters, NOW)) + .map((r) => r.id), + ['f'], + ); + assert.equal(visibleOnMap(minor, { ...filters, minSeverity: 0 }, NOW), true); + assert.equal(visibleOnMap(fresh, { ...filters, hotspot: 'me' }, NOW), false); +}); + +test('ageWords says now, minutes or hours', () => { + assert.equal(ageWords('2026-10-28T09:59:30Z', NOW), 'now'); + assert.equal(ageWords('2026-10-28T09:55:00Z', NOW), '5 min'); + assert.equal(ageWords('2026-10-28T07:10:00Z', NOW), '2 h'); + assert.equal(ageWords('2026-10-26T07:10:00Z', NOW), '24 h'); + assert.equal(ageWords('not a date', NOW), ''); +}); + +test('markers spread deterministically near their place; source link line', () => { + const r = report('r_spread', '2026-10-28T10:00:00Z'); + const p1 = reportMarkerPosition(r); + assert.deepEqual(reportMarkerPosition(r), p1); + assert.ok(Math.abs(p1.lat - 50) < 0.02 && Math.abs(p1.lon - 36) < 0.04); + assert.notDeepEqual( + reportMarkerPosition(report('r_other', r.publishedAt)), + p1, + ); + assert.equal( + sourceLinkLabel('https://www.reliefweb.int/report/x'), + 'Source: reliefweb.int โ†—', + ); + assert.equal(sourceLinkLabel('not a url'), ''); +}); + +test('beacons carry a focus, a size by activity and a trend only against a prior day', () => { + const { hotspots } = normalizeHotspotsPayload({ + hotspots: [ + { + id: 'a', + name: 'A', + bbox: [20, 40, 40, 50], + focus: { lat: 45, lon: 30 }, + counts: { last24h: 120, prev24h: 80 }, + }, + { + id: 'b', + name: 'B', + bbox: [20, 40, 40, 50], + focus: { lat: 80, lon: 30 }, + }, + ], + }); + assert.deepEqual( + hotspots.map((h) => h.focus), + [ + { lat: 45, lon: 30 }, + { lat: 45, lon: 30 }, + ], + ); + assert.equal(hotspotTrend(120, 80), 'โ–ฒ'); + assert.equal(hotspotTrend(50, 80), 'โ–ผ'); + assert.equal(hotspotTrend(85, 80), ''); + assert.equal(hotspotTrend(40, 0), ''); + assert.equal(beaconPixelSize(0), 10); + assert.equal(beaconPixelSize(10_000), 26); + assert.equal( + hotspotLabel({ id: 'ua-ru-war', name: 'Russiaโ€“Ukraine war' }), + 'Ukraineโ€“Russia', + ); + assert.equal(hotspotLabel({ id: 'new-one', name: 'New one' }), 'New one'); + assert.equal( + hotspotLabel({ id: 'x', name: 'X', catchAll: true }), + 'Elsewhere', + ); +}); + +test('marker style: severity fill, attestation rim, size by sources, fading with age', () => { + const r = report('r_style', '2026-10-28T10:00:00Z', { + severity: 2, + event: event({ + status: 'claimed', + size: 4, + lastAt: '2026-10-28T10:00:00Z', + }), + }); + const fresh = markerStyle(r, NOW); + assert.equal(fresh.severity, 3); // the event's, when higher than the report's + assert.equal(fresh.rim, 'claimed'); + assert.equal(fresh.size, eventPixelSize(4)); + assert.equal(fresh.alpha, 1); + assert.equal(markerStyle(r, NOW + 48 * 3_600_000).alpha, 0.45); + assert.equal(reportSeverity({ category: 'political' }), 0); + assert.equal(reportSeverity({ category: 'strike' }), 1); +}); + +test('picture cards: physical, two or more sources, major or pressing, at most ten', () => { + const big = report('big', '2026-10-28T09:30:00Z', { event: event() }); + const statement = report('st', '2026-10-28T09:30:00Z', { + category: 'political', + event: event(), + }); + const lone = report('lone', '2026-10-28T09:30:00Z', { + event: event({ size: 1 }), + }); + const stale = report('stale', '2026-10-28T01:00:00Z', { + event: event({ severity: 2, lastAt: '2026-10-28T01:00:00Z' }), + }); + assert.equal(isPressing(big, NOW), true); + assert.deepEqual( + cardEvents([statement, lone, stale, big], NOW).map((r) => r.id), + ['big'], + ); + const many = Array.from({ length: 14 }, (_, i) => + report(`m${i}`, '2026-10-28T09:30:00Z', { event: event() }), + ); + assert.equal(cardEvents(many, NOW).length, 10); + const view = cardView(big, NOW); + assert.equal(view.image, 'https://a.test/p.jpg'); + assert.equal(view.credit, 'A'); + assert.equal(view.meta, '30 min ยท 6 sources ยท UA, RU'); + assert.equal(cardView(lone, NOW).image, null); +}); + +test('feed cards read like Breaking: headline, meta parts, sides and chips', () => { + const r = report('fc', '2026-10-28T09:00:00Z', { + title: 'ะฃะดะฐั€ ะฟะพ ะฅะฐั€ะบะพะฒัƒ', + titleEn: 'Strike on Kharkiv', + affiliation: 'military', + media: ['video', 'photo'], + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true, byModel: true }, + event: event({ lastAt: '2026-10-28T09:50:00Z' }), + }); + const view = feedCardView(r, NOW); + assert.equal(view.title, 'Strike on Kharkiv'); + assert.equal(view.original, 'ะฃะดะฐั€ ะฟะพ ะฅะฐั€ะบะพะฒัƒ'); + assert.equal(view.placedByModel, true); + assert.equal(view.severityLabel, 'Major'); + assert.equal(view.color, '#d7301f'); + assert.equal(view.category, 'Strike'); + assert.equal(view.place, 'Kharkiv'); + assert.equal(view.sources, '6 sources'); + assert.equal(view.age, '10 min'); + assert.equal(view.sides, 'Reported by UA sources ยท RU sources'); + assert.deepEqual(view.picture, { url: 'https://a.test/p.jpg', credit: 'A' }); + assert.deepEqual( + view.chips.map((c) => [c.kind, c.label]), + [ + ['official', 'Official'], + ['confirmed', 'Reported by both sides'], + ['media', 'Video'], + ['media', 'Photos'], + ], + ); + assert.deepEqual( + view.others.map((o) => o.side), + ['RU'], + ); + // A lone report: its own side, no picture, one source. + const single = feedCardView(report('s', '2026-10-28T09:00:00Z'), NOW); + assert.equal(single.sides, 'Reported by UA sources'); + assert.equal(single.picture, null); + assert.equal(single.original, null); + assert.equal(sidesLine({ side: 'unknown' }), ''); +}); + +test('in view: half-degree cells and reports within 500 km of the centre', () => { + assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73'); + assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73'); + assert.notEqual(viewCell({ lat: 50.6, lon: 36.7 }), '100:73'); + assert.ok( + Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.5, + ); + const filters = normalizeFilters({ minSeverity: 0 }); + const near = report('near', '2026-10-28T09:00:00Z', { severity: 1 }); + const major = report('major', '2026-10-28T08:00:00Z', { severity: 3 }); + const far = report('far', '2026-10-28T09:00:00Z', { + pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }], + }); + const old = report('old', '2026-10-27T08:00:00Z'); + const centre = { lat: 49, lon: 36 }; + assert.ok(distanceKm(centre, far.pins[0]) > NEWS_IN_VIEW_RADIUS_KM); + assert.deepEqual( + reportsInView([near, far, old, major], centre, filters, NOW).map( + (r) => r.id, + ), + ['major', 'near'], + ); + assert.deepEqual(reportsInView([near], null, filters, NOW), []); + assert.deepEqual(reportsInView([], centre, filters, NOW), []); +}); diff --git a/src/layers/news/records.js b/src/layers/news/records.js new file mode 100644 index 000000000..266d17f7c --- /dev/null +++ b/src/layers/news/records.js @@ -0,0 +1,325 @@ +// Validate /api/news payloads before they reach the scene or the DOM. Every +// string is bounded, every URL is http(s), every position is finite; a record +// that fails is dropped, a payload that fails is rejected whole. A portable +// export (layers/news/source): no i18n here. + +const CATEGORY = /^[a-z][a-z-]{1,31}$/; +const CODE = /^[A-Za-z][A-Za-z0-9-]{0,31}$/; +const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/i; +const EVENT_STATUSES = Object.freeze([ + 'both-sides', + 'independent', + 'claimed', + 'reported', + 'side', + 'single', +]); + +const text = (value, max) => + typeof value === 'string' && value.trim() + ? value.replace(/\s+/g, ' ').trim().slice(0, max) + : null; +const finite = (value, min, max) => + Number.isFinite(value) && value >= min && value <= max ? value : null; +const iso = (value) => { + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +}; +const code = (value) => + typeof value === 'string' && CODE.test(value) ? value : null; +const severity = (value) => + Number.isInteger(value) && value >= 0 && value <= 3 ? value : null; + +/** An http(s) URL without credentials, as its normalised href, or null. */ +export function httpUrl(value) { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password + ? url.href + : null; + } catch { + return null; + } +} + +function normalizeBbox(bbox) { + if (!Array.isArray(bbox) || bbox.length !== 4) return null; + const [west, south, east, north] = bbox; + if ( + finite(west, -180, 180) === null || + finite(east, -180, 180) === null || + finite(south, -90, 90) === null || + finite(north, -90, 90) === null || + west >= east || + south >= north + ) + return null; + return [west, south, east, north]; +} + +/** Beacon position: the curated focus point, else the bbox centre. */ +function normalizeFocus(focus, [west, south, east, north]) { + const lat = finite(focus?.lat, south, north); + const lon = finite(focus?.lon, west, east); + return lat !== null && lon !== null + ? { lat, lon } + : { lat: (south + north) / 2, lon: (west + east) / 2 }; +} + +function normalizeDeveloping(items) { + const out = []; + for (const raw of Array.isArray(items) ? items.slice(0, 16) : []) { + const lat = finite(raw?.focus?.lat, -90, 90); + const lon = finite(raw?.focus?.lon, -180, 180); + const country = + typeof raw?.country === 'string' && /^[A-Z]{2}$/.test(raw.country) + ? raw.country + : null; + if (lat === null || lon === null || !country) continue; + // A state where the catch-all flags states ("US-FL"), else the country. + const area = + typeof raw.area === 'string' && /^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(raw.area) + ? raw.area + : null; + out.push({ + country, + key: area || country, + name: text(raw.name, 80) || country, + events: Math.max(0, Math.floor(Number(raw.events) || 0)), + reports: Math.max(0, Math.floor(Number(raw.reports) || 0)), + focus: { lat, lon }, + }); + } + return out; +} + +/** + * `/api/news/hotspots` โ†’ `{hotspots, categories, lastRunAt}`, or null when the + * payload has no hotspot list. + */ +export function normalizeHotspotsPayload(payload) { + if (!Array.isArray(payload?.hotspots)) return null; + const hotspots = []; + for (const raw of payload.hotspots.slice(0, 64)) { + const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null; + const name = text(raw?.name, 80); + const bbox = normalizeBbox(raw?.bbox); + if (!id || !name || !bbox) continue; + const counts = raw.counts || {}; + hotspots.push({ + id, + name, + kind: text(raw.kind, 16) || 'conflict', + catchAll: raw.catchAll === true, + developing: normalizeDeveloping(raw.developing), + bbox, + parties: Array.isArray(raw.parties) + ? raw.parties.filter((p) => code(p)) + : [], + last24h: Math.max(0, Math.floor(Number(counts.last24h) || 0)), + mapped24h: Math.max(0, Math.floor(Number(counts.mapped24h) || 0)), + prev24h: Number.isFinite(counts.prev24h) + ? Math.max(0, Math.floor(counts.prev24h)) + : null, + focus: normalizeFocus(raw.focus, bbox), + latestAt: iso(raw.latestAt), + }); + } + return { + hotspots, + categories: Array.isArray(payload.categories) + ? payload.categories.filter( + (c) => typeof c === 'string' && CATEGORY.test(c), + ) + : [], + lastRunAt: iso(payload.ingest?.lastRunAt), + }; +} + +function normalizeGeo(geo) { + if (!geo || typeof geo !== 'object') return null; + const lat = finite(geo.lat, -90, 90); + const lon = finite(geo.lon, -180, 180); + if (lat === null || lon === null) return null; + return { + lat, + lon, + place: text(geo.place, 80), + precision: text(geo.precision, 16) || 'city', + confidence: finite(geo.confidence, 0, 1) ?? 0, + mappable: geo.mappable === true, + byModel: geo.method === 'llm', + }; +} + +/** The event's picture: an HTTPS URL on the publisher's site, and whose it is. */ +function eventImage(image) { + const url = httpUrl(image?.url); + if (!url?.startsWith('https://')) return null; + return { url, publisher: text(image.publisher, 80) || '' }; +} + +/** An event summary from `/feed?events=1`: size and one report per side. */ +function normalizeEvent(event) { + if (!event || typeof event !== 'object') return null; + const count = (value) => Math.max(1, Math.floor(Number(value) || 1)); + const sides = []; + for (const side of Array.isArray(event.sides) + ? event.sides.slice(0, 8) + : []) { + const url = httpUrl(side?.url); + const title = text(side?.title, 300); + if (!url || !title) continue; + sides.push({ + side: code(side.side) || 'unknown', + title, + url, + publisher: text(side.publisher, 80) || '', + publishedAt: iso(side.publishedAt), + }); + } + return { + id: typeof event.id === 'string' && ID.test(event.id) ? event.id : null, + status: EVENT_STATUSES.includes(event.status) ? event.status : null, + claimedBy: text(event.claimedBy, 80), + reportedSide: code(event.reportedSide), + size: count(event.size), + reports: count(event.reports), + severity: severity(event.severity), + firstAt: iso(event.firstAt), + lastAt: iso(event.lastAt), + sides, + image: eventImage(event.image), + }; +} + +function normalizeReport(raw) { + const id = typeof raw?.id === 'string' && ID.test(raw.id) ? raw.id : null; + const title = text(raw?.title, 300); + const url = httpUrl(raw?.url); + const publishedAt = iso(raw?.publishedAt); + if (!id || !title || !url || !publishedAt) return null; + const source = raw.source || {}; + const geo = normalizeGeo(raw.geo); + // Map positions: the report's own place plus any other hotspot its title + // names (a roundup is pinned in each). + const pins = []; + for (const place of [ + geo, + ...(Array.isArray(raw.locations) + ? raw.locations.slice(0, 8).map(normalizeGeo) + : []), + ]) + if ( + place?.mappable && + !pins.some((p) => p.lat === place.lat && p.lon === place.lon) + ) + pins.push(place); + const hotspotId = typeof raw.hotspotId === 'string' ? raw.hotspotId : null; + return { + id, + hotspotId, + hotspotIds: Array.isArray(raw.hotspotIds) + ? raw.hotspotIds.filter((h) => typeof h === 'string').slice(0, 8) + : [hotspotId].filter(Boolean), + title, + url, + snippet: text(raw.snippet, 400), + publisher: text(raw.publisher, 80) || text(source.name, 80) || '', + sourceName: text(source.name, 80) || text(raw.publisher, 80) || '', + kind: text(raw.kind, 16) || 'rss', + side: code(raw.side) || 'unknown', + affiliation: text(raw.affiliation, 32), + attribution: text(source.attribution, 120), + category: + typeof raw.category === 'string' && CATEGORY.test(raw.category) + ? raw.category + : 'other', + publishedAt, + geo, + pins, + event: normalizeEvent(raw.event), + titleEn: text(raw.titleEn, 200), + relevance: ['core', 'related', 'minor'].includes(raw.relevance) + ? raw.relevance + : 'related', + severity: severity(raw.severity), + severityBy: raw.severityBy === 'model' ? 'model' : 'rules', + claim: + raw.claim && typeof raw.claim === 'object' + ? { by: text(raw.claim.by, 80) } + : null, + media: Array.isArray(raw.media) + ? [...new Set(raw.media.filter((m) => m === 'video' || m === 'photo'))] + : [], + }; +} + +function normalizeRing(ring) { + if (!Array.isArray(ring) || ring.length < 3) return null; + const points = []; + for (const point of ring.slice(0, 20_000)) { + const lon = finite(point?.[0], -180, 180); + const lat = finite(point?.[1], -90, 90); + if (lon !== null && lat !== null) points.push([lon, lat]); + } + return points.length >= 3 ? points : null; +} + +/** + * `/api/news/outline` โ†’ `{polygons, borders, disputed}`: the dissolved extent + * (`{outer, holes}`), the national borders inside it (rings) and its disputed + * areas, invalid parts dropped. + */ +export function normalizeOutlinePayload(payload) { + const polygons = []; + for (const raw of Array.isArray(payload?.polygons) + ? payload.polygons.slice(0, 200) + : []) { + const outer = normalizeRing(raw?.outer); + if (!outer) continue; + const holes = (Array.isArray(raw.holes) ? raw.holes.slice(0, 200) : []) + .map(normalizeRing) + .filter(Boolean); + polygons.push({ outer, holes }); + } + const borders = ( + Array.isArray(payload?.borders) ? payload.borders.slice(0, 50) : [] + ) + .map(normalizeRing) + .filter(Boolean); + const KINDS = ['held', 'self', 'un', 'unclaimed']; + const country = (value) => + typeof value === 'string' && /^[A-Z]{2}$/.test(value) ? value : null; + const disputed = []; + for (const raw of Array.isArray(payload?.disputed) + ? payload.disputed.slice(0, 30) + : []) { + const rings = (Array.isArray(raw?.rings) ? raw.rings.slice(0, 20) : []) + .map(normalizeRing) + .filter(Boolean); + const name = text(raw?.name, 60); + if (!rings.length || !name) continue; + disputed.push({ + name, + kind: KINDS.includes(raw.kind) ? raw.kind : 'held', + heldBy: country(raw.heldBy), + claimedBy: country(raw.claimedBy), + rings, + }); + } + return { polygons, borders, disputed }; +} + +/** `/api/news/feed` โ†’ `{reports, nextBefore}`, or null without a report list. */ +export function normalizeFeedPayload(payload) { + if (!Array.isArray(payload?.reports)) return null; + const reports = []; + for (const raw of payload.reports.slice(0, 500)) { + const report = normalizeReport(raw); + if (report) reports.push(report); + } + return { reports, nextBefore: iso(payload.nextBefore) }; +} diff --git a/src/layers/news/source.js b/src/layers/news/source.js new file mode 100644 index 000000000..8c739584b --- /dev/null +++ b/src/layers/news/source.js @@ -0,0 +1,162 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; +import { isUnavailableCapability } from '../../sources/capability.js'; +import { + normalizeFeedPayload, + normalizeHotspotsPayload, + normalizeOutlinePayload, +} from './records.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the layer shows them translated. +const msg = (text) => text; + +const MAX_BYTES = 4 * 1024 * 1024; +const BASE_PATH = '/api/news'; +// The outline geometry version the client draws (disputed areas included). +const OUTLINE_VERSION = '3'; + +/** The three read routes the news layer calls, for a host's path allowlist. */ +export const NEWS_API_PATHS = Object.freeze([ + `${BASE_PATH}/hotspots`, + `${BASE_PATH}/feed`, + `${BASE_PATH}/outline`, +]); + +const FEED_KEYS = Object.freeze([ + 'hotspot', + 'before', + 'since', + 'limit', + 'category', + 'relevance', +]); +const FLAG_KEYS = Object.freeze(['geo', 'today', 'events']); +const ISO = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/; +const LIST = /^[a-z][a-z,-]{0,127}$/; +const FEED_VALUE = { + hotspot: (v) => /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(v), + before: (v) => ISO.test(v), + since: (v) => ISO.test(v), + limit: (v) => /^\d{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 500, + category: (v) => LIST.test(v), + relevance: (v) => LIST.test(v), +}; + +/** Feed query string from a plain object; unknown keys and empty values are dropped. */ +export function newsFeedQuery(query = {}) { + const params = new URLSearchParams(); + for (const key of FEED_KEYS) { + const value = query[key]; + if (value !== undefined && value !== null && value !== '') + params.set(key, String(value)); + } + for (const key of FLAG_KEYS) if (query[key]) params.set(key, '1'); + return params.toString(); +} + +/** + * Whether a request URL is one this client sends: a known route, known keys + * only, each value in its form. A host proxying the routes can admit exactly + * these and refuse the rest. + * @param {URL} url + */ +export function newsQueryAllowed(url) { + const params = [...url.searchParams]; + if (new Set(params.map(([key]) => key)).size !== params.length) return false; + if (url.pathname === `${BASE_PATH}/hotspots`) return params.length === 0; + if (url.pathname === `${BASE_PATH}/outline`) { + const keys = params.map(([key]) => key).sort(); + const value = url.searchParams; + return ( + params.length === 2 && + keys[1] === 'v' && + value.get('v') === OUTLINE_VERSION && + ((keys[0] === 'hotspot' && FEED_VALUE.hotspot(value.get('hotspot'))) || + (keys[0] === 'country' && + /^[A-Z]{2}(?:-[A-Z0-9]{1,3})?$/.test(value.get('country')))) + ); + } + if (url.pathname !== `${BASE_PATH}/feed`) return false; + return params.every(([key, value]) => + FLAG_KEYS.includes(key) + ? value === '1' + : Object.hasOwn(FEED_VALUE, key) && FEED_VALUE[key](value), + ); +} + +/** A failed read: a server without the news routes is a permanent miss. */ +async function responseError(response) { + let body = null; + try { + body = await readResponseJsonCapped(response, 16 * 1024); + } catch { + await response.body?.cancel?.().catch?.(() => {}); + } + const unavailable = + response.status === 404 || + (response.status === 503 && isUnavailableCapability(body)); + return Object.assign( + new Error( + unavailable + ? msg('News feed not configured on this server') + : msg('News feed unavailable'), + ), + { + status: response.status, + code: unavailable ? 'unavailable' : 'http', + unavailable, + retryable: !unavailable, + }, + ); +} + +/** Read hotspots, reports and outlines from the server's /api/news routes. */ +export function createNewsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + timeoutMs = 15_000, +} = {}) { + async function get(path, { signal } = {}) { + signal?.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const request = signal ? AbortSignal.any([signal, timeout]) : timeout; + const response = await fetchImpl(`${BASE_PATH}${path}`, { + signal: request, + cache: 'no-store', + redirect: 'error', + }); + if (!response.ok) throw await responseError(response); + return readResponseJsonCapped(response, MAX_BYTES, request); + } + return { + async getHotspots({ signal } = {}) { + const payload = normalizeHotspotsPayload( + await get('/hotspots', { signal }), + ); + if (!payload) throw new Error(msg('Malformed news hotspots response')); + return payload; + }, + async getOutline(hotspotId, { signal } = {}) { + return normalizeOutlinePayload( + await get( + `/outline?hotspot=${encodeURIComponent(hotspotId)}&v=${OUTLINE_VERSION}`, + { signal }, + ), + ); + }, + async getCountryOutline(country, { signal } = {}) { + return normalizeOutlinePayload( + await get( + `/outline?country=${encodeURIComponent(country)}&v=${OUTLINE_VERSION}`, + { signal }, + ), + ); + }, + async getFeed(query = {}, { signal } = {}) { + const payload = normalizeFeedPayload( + await get(`/feed?${newsFeedQuery(query)}`, { signal }), + ); + if (!payload) throw new Error(msg('Malformed news feed response')); + return payload; + }, + }; +} diff --git a/src/layers/news/source.test.mjs b/src/layers/news/source.test.mjs new file mode 100644 index 000000000..3f48093ea --- /dev/null +++ b/src/layers/news/source.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + NEWS_API_PATHS, + createNewsSource, + newsFeedQuery, + newsQueryAllowed, +} from './source.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +function recorder(respond) { + const calls = []; + return { + calls, + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return respond(url, init); + }, + }; +} + +test('reads validated hotspots, feed and outlines from the same-origin routes', async () => { + const { calls, fetchImpl } = recorder((url) => { + if (url.startsWith('/api/news/hotspots')) + return json({ + hotspots: [{ id: 'ua', name: 'UA', bbox: [22, 44, 42, 56] }], + }); + if (url.startsWith('/api/news/feed')) + return json({ + reports: [ + { + id: 'r1', + title: 'T', + url: 'https://x.test/', + publishedAt: '2026-10-28T09:00:00Z', + }, + ], + nextBefore: null, + }); + return json({ polygons: [], borders: [], disputed: [] }); + }); + const source = createNewsSource({ fetchImpl }); + assert.equal((await source.getHotspots()).hotspots[0].id, 'ua'); + assert.equal( + (await source.getFeed({ hotspot: 'all', today: true, limit: 60 })) + .reports[0].id, + 'r1', + ); + assert.deepEqual((await source.getOutline('ua')).polygons, []); + await source.getCountryOutline('SD'); + assert.deepEqual( + calls.map((c) => c.url), + [ + '/api/news/hotspots', + '/api/news/feed?hotspot=all&limit=60&today=1', + '/api/news/outline?hotspot=ua&v=3', + '/api/news/outline?country=SD&v=3', + ], + ); + for (const { url, init } of calls) { + assert.equal(init.redirect, 'error'); + assert.equal(init.cache, 'no-store'); + assert.ok(init.signal instanceof AbortSignal); + // Every URL the client sends is one a host's allowlist admits. + assert.equal(newsQueryAllowed(new URL(url, 'http://h')), true, url); + } +}); + +test('malformed, failed and unconfigured reads reject; a server without news is a permanent miss', async () => { + const malformed = createNewsSource({ fetchImpl: async () => json({}) }); + await assert.rejects(malformed.getHotspots(), /Malformed news hotspots/); + await assert.rejects(malformed.getFeed(), /Malformed news feed/); + + const down = createNewsSource({ + fetchImpl: async () => json({ error: 'upstream' }, 502), + }); + await assert.rejects(down.getHotspots(), (error) => { + assert.equal(error.message, 'News feed unavailable'); + assert.equal(error.retryable, true); + assert.equal(error.unavailable, false); + return true; + }); + + const unconfigured = createNewsSource({ + fetchImpl: async () => + json( + { error: 'news_not_configured', unavailable: true, retryable: false }, + 503, + ), + }); + await assert.rejects(unconfigured.getHotspots(), (error) => { + assert.equal(error.message, 'News feed not configured on this server'); + assert.equal(error.code, 'unavailable'); + assert.equal(error.unavailable, true); + assert.equal(error.retryable, false); + return true; + }); + const missing = createNewsSource({ + fetchImpl: async () => new Response('Not found', { status: 404 }), + }); + await assert.rejects(missing.getFeed(), { unavailable: true }); + const broken = createNewsSource({ + fetchImpl: async () => new Response('{', { status: 200 }), + }); + await assert.rejects(broken.getHotspots(), SyntaxError); +}); + +test('reads time out and follow the caller signal', async () => { + const hang = (_url, { signal }) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }), + ); + const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 }); + await assert.rejects(slow.getHotspots(), { name: 'TimeoutError' }); + const caller = new AbortController(); + const pending = createNewsSource({ fetchImpl: hang }).getFeed( + {}, + { signal: caller.signal }, + ); + caller.abort(new Error('gone')); + await assert.rejects(pending, /gone/); + await assert.rejects( + createNewsSource({ fetchImpl: hang }).getHotspots({ + signal: AbortSignal.abort(new Error('already')), + }), + /already/, + ); +}); + +test('the host allowlist admits the client routes and refuses anything else', () => { + const allowed = (path) => newsQueryAllowed(new URL(path, 'http://h')); + assert.deepEqual(NEWS_API_PATHS, [ + '/api/news/hotspots', + '/api/news/feed', + '/api/news/outline', + ]); + assert.equal( + allowed( + `/api/news/feed?${newsFeedQuery({ hotspot: 'ua-ru-war', before: '2026-10-28T08:00:00.000Z', limit: 500, category: 'strike', relevance: 'core,related', geo: true, today: true, events: true })}`, + ), + true, + ); + for (const path of [ + '/api/news/hotspots?x=1', + '/api/news/feed?limit=501', + '/api/news/feed?limit=0', + '/api/news/feed?hotspot=all&hotspot=ua', + '/api/news/feed?since=yesterday', + '/api/news/feed?geo=yes', + '/api/news/feed?side=UA', + '/api/news/air-alerts', + '/api/news/outline?hotspot=ua', + '/api/news/outline?hotspot=ua&v=2', + '/api/news/outline?country=ukraine&v=3', + ]) + assert.equal(allowed(path), false, path); +}); diff --git a/src/layers/news/sprites.js b/src/layers/news/sprites.js new file mode 100644 index 000000000..ac7c71ac5 --- /dev/null +++ b/src/layers/news/sprites.js @@ -0,0 +1,94 @@ +// Marker sprites for news reports: a disc filled with the severity colour, +// the category's icon in it, and a rim for attestation. Drawn once per look +// on a canvas and handed to Cesium as a data URL, so its texture atlas keeps +// one copy per look however often the layer re-renders. + +import { NEWS_CATEGORIES, NEWS_SEVERITY } from './model.js'; + +const CANVAS = 64; +const BODY = 52; // disc diameter inside the canvas; the rest is rim and ring +const INK = '#0a0a0f'; +const RIM = '#ffffff'; +const SELECTED = '#00d4ff'; + +/** Canvas pixels per marker pixel: the billboard is drawn at this ratio. */ +export const SPRITE_SCALE = CANVAS / BODY; + +const cache = new Map(); + +/** + * Paint one marker look centred at (cx, cy) with a disc of `body` pixels: the + * sprites and the event cards' badges draw the same thing. + */ +export function paintMarker(ctx, { category, severity, rim }, cx, cy, body) { + const k = body / BODY; // the strokes below are tuned for the 52 px body + const r = body / 2; + // A dark edge keeps the lightest fills apart from bright ground. + ctx.beginPath(); + ctx.arc(cx, cy, r + 1.5 * k, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2 * Math.PI); + ctx.fillStyle = (NEWS_SEVERITY[severity] || NEWS_SEVERITY[0]).color; + ctx.fill(); + + // Attestation is the rim's form, never its colour. + if (rim === 'confirmed' || rim === 'claimed') { + ctx.beginPath(); + ctx.arc(cx, cy, r - 3.5 * k, 0, 2 * Math.PI); + ctx.setLineDash(rim === 'claimed' ? [7 * k, 5 * k] : []); + ctx.lineWidth = 7 * k; + ctx.strokeStyle = RIM; + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.beginPath(); + ctx.arc(cx, cy, r - 1 * k, 0, 2 * Math.PI); + ctx.lineWidth = 2 * k; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.75)'; + ctx.stroke(); + } + + const style = NEWS_CATEGORIES[category] || NEWS_CATEGORIES.other; + const icon = body * 0.56; + ctx.save(); + ctx.translate(cx - icon / 2, cy - icon / 2); + ctx.scale(icon / 16, icon / 16); + const path = new Path2D(style.icon); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = 1.7; + ctx.strokeStyle = INK; + if (style.solid) { + ctx.fillStyle = INK; + ctx.fill(path); + } else ctx.stroke(path); + ctx.restore(); +} + +/** + * Data URL of the sprite for a look, or null where there is no canvas (the + * layer then falls back to a plain point). + * @param {{category: string, severity: number, rim: 'confirmed'|'claimed'|'thin', selected?: boolean}} look + */ +export function markerSprite({ category, severity, rim, selected = false }) { + const key = `${category}|${severity}|${rim}|${selected ? 1 : 0}`; + if (cache.has(key)) return cache.get(key); + const canvas = globalThis.document?.createElement?.('canvas'); + const ctx = canvas?.getContext?.('2d'); + if (!ctx || typeof globalThis.Path2D !== 'function') return null; + canvas.width = canvas.height = CANVAS; + const c = CANVAS / 2; + paintMarker(ctx, { category, severity, rim }, c, c, BODY); + if (selected) { + ctx.beginPath(); + ctx.arc(c, c, CANVAS / 2 - 2, 0, 2 * Math.PI); + ctx.lineWidth = 3.5; + ctx.strokeStyle = SELECTED; + ctx.stroke(); + } + const url = canvas.toDataURL('image/png'); + cache.set(key, url); + return url; +} From 5bb489d1fb2c2460a3567029393745056a3c324e Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 5 Nov 2026 20:03:44 +0100 Subject: [PATCH 11/86] Put news on the globe, with a feed panel in the right rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events โ–ธ News draws the last 24 hours of reports from curated hotspots (Ukraineโ€“Russia, Israelโ€“Gazaโ€“Lebanon, Iranโ€“Hormuz, Yemenโ€“Red Sea, Sudan, Sahel) and developing situations elsewhere, from any server that answers the `/api/news` read routes. - Globe: one marker per merged event, clamped to the ground and culled at the horizon; one beacon per hotspot when zoomed out and a pulsing beacon for a developing situation; hotspot outlines with disputed areas dotted and labelled by who administers and who claims them; picture cards for at most ten major or fast-growing events with two or more sources, never overlapping, switchable off. - Panel: hotspot chips, a severity filter (significant and above by default), categories, minor news off by default, and a scrolling list of event cards with picture, headline, place, sources, age and the sides that reported it. "In view" lists what is near the map below 3,000 km once the camera settles. A card flies the globe to its event and opens it with a link to the publisher. Reduced motion stops the pulse and the flights. - Core serves no news itself: the local server proxies GEV_NEWS_ORIGIN when it is set, and answers 503 unavailable otherwise, so the layer reads "not configured" once instead of retrying. - Share token 9 (enabled-only); voice stays off for this layer. --- .env.example | 5 + DATA_SOURCES.md | 1 + changelog.d/1-news-layer.md | 6 + docs/CURRENT-STATE.md | 8 + scripts/package-boundaries.json | 29 + server/providers/local.js | 2 + server/providers/news.js | 109 ++ server/providers/news.test.mjs | 135 +++ src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/news.js | 6 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 31 +- src/data/dataCredits.js | 8 + src/data/layerState.js | 1 + src/data/layerState.test.mjs | 34 +- src/data/layerStateTokenLedger.test.mjs | 64 +- src/data/layerStateTokenReservations.json | 1 + src/i18n/locales/ar.json | 46 + src/i18n/locales/de.json | 38 + src/i18n/locales/es.json | 40 + src/i18n/locales/fr.json | 40 + src/i18n/locales/it.json | 40 + src/i18n/locales/ja.json | 36 + src/i18n/locales/pt.json | 40 + src/i18n/locales/ru.json | 42 + src/i18n/locales/uk.json | 42 + src/i18n/locales/zh-CN.json | 36 + src/i18n/locales/zh-Hant.json | 36 + src/layers/news/index.js | 1240 +++++++++++++++++++++ src/layers/news/layer.test.mjs | 255 +++++ src/navigationPolicy.js | 2 + src/navigationPolicy.test.mjs | 1 + src/standalone/layerSources.js | 5 + src/ui/layerBindings.js | 9 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 20 + src/ui/news/NewsCard.vue | 127 +++ src/ui/news/NewsFeed.vue | 217 ++++ src/ui/newsPanel.js | 73 ++ src/ui/newsPanel.test.mjs | 223 ++++ src/ui/panelChrome.js | 2 + src/ui/panelLayoutController.js | 3 + src/ui/panelRailCatalog.js | 1 + src/ui/styles/news.css | 378 +++++++ src/ui/views/ContextView.vue | 4 + src/voice/layerManifest.js | 1 + style.css | 1 + 48 files changed, 3394 insertions(+), 53 deletions(-) create mode 100644 changelog.d/1-news-layer.md create mode 100644 server/providers/news.js create mode 100644 server/providers/news.test.mjs create mode 100644 src/app/layers/news.js create mode 100644 src/layers/news/index.js create mode 100644 src/layers/news/layer.test.mjs create mode 100644 src/ui/news/NewsCard.vue create mode 100644 src/ui/news/NewsFeed.vue create mode 100644 src/ui/newsPanel.js create mode 100644 src/ui/newsPanel.test.mjs create mode 100644 src/ui/styles/news.css diff --git a/.env.example b/.env.example index b3de4cefa..d026e35e2 100644 --- a/.env.example +++ b/.env.example @@ -317,3 +317,8 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite # Each reserves up to 40 stations within the existing 750-station directory. # Restart after changing. Leave blank for the existing global selection. # RADIO_PRIORITY_COUNTRIES=IN,PL +# Optional: a news server for the News layer (its /api/news/hotspots, /feed +# and /outline routes), such as https://news.example. The dev server forwards +# only the layer's own GET requests there, with a 15 s timeout. Unset, Core +# serves no news and the layer shows itself unavailable. SERVER-SIDE ONLY. +# GEV_NEWS_ORIGIN= diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ed0895b0a..5bddb2349 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -39,6 +39,7 @@ How to read this: | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use โ€” one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." โ€” [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground ยฉ 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground ยฉ by S2A1, CC BY-NC-SA 4.0" โ€” shown in-app | +| **News server** (`GEV_NEWS_ORIGIN`, optional; none by default) | News layer: curated hotspots and the last 24 h of reports from many publishers, merged into events. Core serves no news itself; the local server forwards the layer's `/api/news/hotspots`, `/feed` and `/outline` GET requests to the configured server | Each report stays its publisher's: headlines and summaries link to the original article, and pictures are hotlinked from the publisher's site (no referrer), never stored | Per report: publisher name and article link on every card; picture cards name the picture's publisher. No single provider is credited for the feed | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb โ†—" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only โ€” not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap ยฉ OpenMapTiles | diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md new file mode 100644 index 000000000..081316b9d --- /dev/null +++ b/changelog.d/1-news-layer.md @@ -0,0 +1,6 @@ +- Add the News layer (Events โ–ธ News) and its right-rail NEWS panel: the last + 24 hours from curated hotspots, coloured by severity only, with hotspot + beacons, outlines with disputed areas, picture cards for major events, an + "In view" list below 3,000 km and a continuous feed whose cards fly the globe + to the event. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news + server and the local server forwards the layer's three read routes there. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 084ad4f83..4a52c0d74 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4216,6 +4216,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) | +| News โ—† (`news`) | A news server's `/api/news` read routes (curated hotspots, last 24 h of reports merged into events, outlines); Core serves none itself | `src/layers/news/` via `src/app/layers/news.js`; panel `src/ui/newsPanel.js` | `/api/news/hotspots`, `/api/news/feed`, `/api/news/outline` (local dev proxies `GEV_NEWS_ORIGIN`; unset answers "not configured") | 2 min map refresh; In view re-read when the camera settles 0.8 s in a new half-degree cell below 3,000 km | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; @@ -5846,6 +5847,13 @@ request viewbox and continues searching without that geographic preference. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles โ€” leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` โ†’ `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### News (November 2026) + +- **What it shows.** Events โ–ธ **News**: reports from curated hotspots (Ukraineโ€“Russia, Israelโ€“Gazaโ€“Lebanon, Iranโ€“Hormuz, Yemenโ€“Red Sea, Sudan, Sahel, and developing situations elsewhere), the last 24 hours only. Zoomed out, one beacon per hotspot with its daily count and trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every geocoded report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping; "Show event cards" off leaves only the selected one. A selected report's card reads "Source: host โ†—" and opens the article. +- **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) shows "Last 24 h", hotspot chips, severity (significant and above by default), category, "Include minor news" (off) and "Show event cards" (on), "In view" (reports within 500 km of the view centre while the camera is below 3,000 km) and a continuous feed of event cards: the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (the model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), "Reported by UA sources ยท RU sources", attestation and media chips. A click flies the camera straight down from 150 km and selects the report. +- **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN`; unset, every route answers 503 `news_not_configured` and the layer reads unavailable without retrying until it is turned off and on. The feed has no area query, so In view filters the day's geocoded reports by distance. +- **State.** Share/restore token `6`, enabled-only. Voice is off for this layer (`VOICE_OFF_LAYERS`). + ### S2 GhostMaps (October 2026) - **What it shows.** Events โ–ธ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 328112590..8e3591938 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -727,6 +727,11 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiEmpty.vue", + "src/ui/kit/UiSelect.js", "src/ui/kit/UiButton.vue", "src/ui/kit/UiBadge.vue", "src/ui/rail/fieldValue.js", @@ -1131,6 +1136,11 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiEmpty.vue", + "src/ui/kit/UiSelect.js", "src/ui/stores/streetLevel.js", "src/ui/stores/cctv.js", "src/ui/stores/radio.js", @@ -1190,6 +1200,7 @@ "src/ui/styles/first-run.css", "src/ui/styles/provider-settings.css", "src/ui/styles/weather.css", + "src/ui/styles/news.css", "src/ui/styles/recent-imagery.css" ], "external": [] @@ -2960,6 +2971,13 @@ "src/layers/droneAirspace/source.js", "src/layers/droneAirspace/records.js", "src/layers/droneAirspace/cards.js", + "src/app/layers/news.js", + "src/layers/news/index.js", + "src/layers/news/model.js", + "src/layers/news/records.js", + "src/layers/news/source.js", + "src/layers/news/sprites.js", + "src/layers/news/cards.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -3212,6 +3230,10 @@ "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js", "src/ui/weather/WeatherReadout.vue", + "src/ui/newsPanel.js", + "src/ui/news/NewsFeed.vue", + "src/ui/news/NewsCard.vue", + "src/ui/kit/UiSelect.js", "src/ui/recentImagery/RecentImageryCard.vue", "src/ui/recentImagery/RecentImageryReadout.vue", "src/ui/stores/streetLevel.js", @@ -3714,6 +3736,13 @@ "src/layers/droneAirspace/source.js", "src/layers/droneAirspace/records.js", "src/layers/droneAirspace/cards.js", + "src/app/layers/news.js", + "src/layers/news/index.js", + "src/layers/news/model.js", + "src/layers/news/records.js", + "src/layers/news/source.js", + "src/layers/news/sprites.js", + "src/layers/news/cards.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", diff --git a/server/providers/local.js b/server/providers/local.js index 7067f3ec5..a7005db23 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -29,6 +29,7 @@ import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { ghostMapsProxy } from './ghostMaps.js'; import { droneAirspaceProxy } from './droneAirspace.js'; +import { newsProxy } from './news.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -81,6 +82,7 @@ function localProviderPlugins({ gnssIntegrityProxy(), ghostMapsProxy(), droneAirspaceProxy(), + newsProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/news.js b/server/providers/news.js new file mode 100644 index 000000000..5d10ab2b1 --- /dev/null +++ b/server/providers/news.js @@ -0,0 +1,109 @@ +import { newsQueryAllowed } from '../../src/layers/news/source.js'; +import { readResponseTextCapped } from './common/http.js'; + +// News (/api/news/*): Core serves no news of its own. With GEV_NEWS_ORIGIN +// set (a news server such as https://news.example), this forwards exactly +// the GET requests the news layer sends there and relays the JSON; without +// it, every route answers "not configured" and the layer shows itself +// unavailable without retrying. + +const TIMEOUT_MS = 15_000; +const MAX_BYTES = 4 * 1024 * 1024; + +/** + * The configured news server's origin, or null: http(s) only, no + * credentials, query or fragment; a path is ignored. + * @param {string|undefined} raw + */ +export function newsOrigin(raw = process.env.GEV_NEWS_ORIGIN) { + const text = String(raw ?? '').trim(); + if (!text) return null; + try { + const url = new URL(text); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) + return null; + return url.origin; + } catch { + return null; + } +} + +/** + * A thin, validated GET proxy for the news layer's three read routes. + * @param {{origin?: () => string|null, fetchImpl?: typeof fetch, timeoutMs?: number}} [options] + */ +export function newsProxy({ + origin = () => newsOrigin(), + fetchImpl = (...args) => globalThis.fetch(...args), + timeoutMs = TIMEOUT_MS, +} = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(typeof value === 'string' ? value : JSON.stringify(value)); + }; + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const local = new URL(req.url || '/', 'http://local'); + const target = new URL( + `/api/news${local.pathname}${local.search}`, + 'http://local', + ); + if (!newsQueryAllowed(target)) + return json(res, 400, { error: 'invalid_news_request' }); + const base = origin(); + if (!base) + return json(res, 503, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); + try { + const upstream = await fetchImpl( + new URL(`${target.pathname}${target.search}`, base), + { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }, + ); + if (!upstream.ok) { + await upstream.body?.cancel().catch(() => {}); + return json(res, 502, { + error: 'news_upstream_error', + status: upstream.status, + }); + } + const text = await readResponseTextCapped(upstream, MAX_BYTES); + // Relayed only as JSON; the browser validates every record. + JSON.parse(text); + return json(res, 200, text); + } catch (error) { + return json(res, error?.name === 'TimeoutError' ? 504 : 502, { + error: 'news_unavailable', + }); + } + } + + return { + name: 'news', + configureServer({ middlewares }) { + middlewares.use('/api/news', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/news', handler); + }, + }; +} diff --git a/server/providers/news.test.mjs b/server/providers/news.test.mjs new file mode 100644 index 000000000..31e3ad4bb --- /dev/null +++ b/server/providers/news.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { newsOrigin, newsProxy } from './news.js'; + +/** Mount the proxy on a fake middleware stack and call it like Connect does. */ +function mount(options) { + let handler = null; + newsProxy(options).configureServer({ + middlewares: { + use(prefix, fn) { + assert.equal(prefix, '/api/news'); + handler = fn; + }, + }, + }); + return async (url, method = 'GET') => { + const res = { + destroyed: false, + status: 0, + headers: {}, + body: '', + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = body; + }, + }; + await handler({ url, method }, res); + return { ...res, json: res.body ? JSON.parse(res.body) : null }; + }; +} + +test('the origin must be a plain http(s) origin', () => { + assert.equal(newsOrigin('https://news.example'), 'https://news.example'); + assert.equal( + newsOrigin('https://news.example/base/'), + 'https://news.example', + ); + for (const bad of [ + undefined, + '', + ' ', + 'ftp://x', + 'http://u:p@x', + 'http://x/?a=1', + 'http://x/#f', + 'not a url', + ]) + assert.equal(newsOrigin(bad), null, String(bad)); +}); + +test('without GEV_NEWS_ORIGIN every route answers not configured and nothing is fetched', async () => { + let fetches = 0; + const call = mount({ + origin: () => null, + fetchImpl: async () => { + fetches++; + throw new Error('unexpected'); + }, + }); + const response = await call('/hotspots'); + assert.equal(response.status, 503); + assert.deepEqual(response.json, { + error: 'news_not_configured', + unavailable: true, + retryable: false, + }); + assert.equal(fetches, 0); +}); + +test('forwards only the client routes, by GET, and relays the JSON', async () => { + const calls = []; + const call = mount({ + origin: () => 'https://news.example', + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response('{"reports":[],"nextBefore":null}', { status: 200 }); + }, + }); + const ok = await call('/feed?hotspot=all&limit=60&today=1&events=1'); + assert.equal(ok.status, 200); + assert.deepEqual(ok.json, { reports: [], nextBefore: null }); + assert.equal(ok.headers['Cache-Control'], 'no-store'); + assert.equal( + calls[0].url, + 'https://news.example/api/news/feed?hotspot=all&limit=60&today=1&events=1', + ); + assert.equal(calls[0].init.redirect, 'error'); + assert.ok(calls[0].init.signal instanceof AbortSignal); + + for (const url of [ + '/air-alerts', + '/feed?side=UA', + '/feed?limit=9999', + '/../admin', + '/hotspots?x=1', + ]) { + const refused = await call(url); + assert.equal(refused.status, 400, url); + } + const post = await call('/hotspots', 'POST'); + assert.equal(post.status, 405); + assert.equal(post.headers.Allow, 'GET'); + assert.equal(calls.length, 1); +}); + +test('upstream failures, bad JSON and timeouts become bounded errors', async () => { + const failing = mount({ + origin: () => 'https://news.example', + fetchImpl: async () => new Response('down', { status: 500 }), + }); + const down = await failing('/hotspots'); + assert.equal(down.status, 502); + assert.deepEqual(down.json, { error: 'news_upstream_error', status: 500 }); + + const garbled = mount({ + origin: () => 'https://news.example', + fetchImpl: async () => new Response('', { status: 200 }), + }); + assert.equal((await garbled('/hotspots')).status, 502); + + const slow = mount({ + origin: () => 'https://news.example', + timeoutMs: 10, + fetchImpl: (_url, { signal }) => + new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ), + }); + const timedOut = await slow('/hotspots'); + assert.equal(timedOut.status, 504); + assert.deepEqual(timedOut.json, { error: 'news_unavailable' }); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 488a5e9ab..2b1313ba9 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -30,6 +30,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; +import { createApplicationNews } from './layers/news.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; @@ -137,6 +138,7 @@ export function createApplicationCatalog({ createApplicationDroneAirspace({ source: sources['drone-airspace'], }), + createApplicationNews({ source: sources.news }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 82a1dbcf0..1600f5499 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 37); + assert.equal(first.layers.length, 38); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'osm-places'), @@ -68,6 +68,7 @@ test('catalogs construct distinct layers and classification from their supplied assert.ok(first.get('gnss-integrity')); assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('drone-airspace')); + assert.ok(first.get('news')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -161,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, 37); + assert.equal(catalog.layers.length, 38); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/news.js b/src/app/layers/news.js new file mode 100644 index 000000000..552ad20db --- /dev/null +++ b/src/app/layers/news.js @@ -0,0 +1,6 @@ +import { createNewsLayer } from '../../layers/news/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire the news layer to the application overlay host. */ +export function createApplicationNews(options) { + return createNewsLayer({ overlayHost, ...options }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index a12378260..9cf47b3a9 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), + news: contract(['news'], ['getHotspots', 'getFeed', 'getOutline']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index cee9c1246..28f2d3984 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 37); + assert.equal(actual.layers.length, 38); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -150,6 +150,35 @@ test('GhostMaps obeys source omission, replacement and explicit removal', async assert.equal(fetches, 0); }); +test('News is unavailable without a source and the standalone default reads only same-origin /api/news', async (t) => { + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('news')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('news')); + assert.equal(await manager.setEnabled('news', true), false); + assert.equal(manager.layers.get('news').initialized, false); + assert.equal(createStandaloneLayerSources({ news: null }).news, null); + + const urls = []; + t.mock.method(globalThis, 'fetch', async (url) => { + urls.push(url); + return new Response( + JSON.stringify({ + error: 'news_not_configured', + unavailable: true, + retryable: false, + }), + { status: 503 }, + ); + }); + const standalone = createStandaloneLayerSources().news; + await assert.rejects(standalone.getHotspots(), { unavailable: true }); + assert.deepEqual(urls, ['/api/news/hotspots']); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 29075550c..ede5dc669 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -492,6 +492,14 @@ export const DATA_CREDITS = [ 'licensed under CC BY-NC-SA 4.0. ' + 'Shown as published; reports are S2 assessments, not verified fact.', }, + { + key: 'news', + html: + 'News: every report is credited to its own publisher and links to the original article; ' + + 'pictures are the publishersโ€™ own, shown from their sites with the publisher named. ' + + 'No single provider stands behind the feed. Places, categories, severity and English ' + + 'headlines are derived automatically by the news server and can be wrong.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index 46dadf9a0..2c149a192 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -662,6 +662,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'i', disposition: 'enabled-only', }), + Object.freeze({ id: 'news', token: '9', disposition: 'enabled-only' }), Object.freeze({ id: 'radio', token: 'r', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9d30e8973..9a8f824d8 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -203,8 +203,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 35); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35); + assert.equal(REGISTERED_LAYER_IDS.length, 36); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -243,19 +243,17 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '9'); + // News took the last single digit: the next tokens are pairs. + assert.equal(nextLayerStateToken(), '00'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '9', - bravo: '00', + alpha: '00', + bravo: '01', }), - '01', + '02', ); - const digitsExhausted = { - ...LAYER_STATE_TOKEN_RESERVATIONS, - ...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])), - }; + const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); assert.equal( nextLayerStateToken({ ...digitsExhausted, retired: '00', used: '01' }), @@ -306,8 +304,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '9', - next: '00', + future: '00', + next: '01', }), true, ); @@ -315,12 +313,12 @@ test('production registry is exact, canonical, and rejects incomplete contracts' () => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '00', + future: '01', }), - /next free token 9/, + /next free token 00/, ); const beforeLastDigit = { ...digitsExhausted }; - delete beforeLastDigit['prior-9']; + delete beforeLastDigit.news; assert.equal( validateLayerStateAllocations(beforeLastDigit, { ...beforeLastDigit, @@ -340,10 +338,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '00', competing: '00' }, ), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index d46782378..04d25dce6 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -140,6 +140,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'sea-marks': '6', 'ski-pistes': '7', 'drone-airspace': '8', + news: '9', }, ); assert.throws( @@ -165,9 +166,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '9']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '00']]) .future, - '9', + '00', ); assert.throws( () => @@ -185,14 +186,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '9']] }, + { rows: [...reservationRows, ['retired-layer', '00']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); + assert.equal(nextLayerStateToken(published), '01'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '00', + newcomer: '01', }), true, ); @@ -200,15 +201,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '9', + competing: '00', }), - /next free token 00/, + /next free token 01/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '9', + newcomer: '00', }), /changed or removed: retired-layer/, ); @@ -216,8 +217,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => { +test('PR B manually replaces provisional 00 with 01 after PR A publishes 00', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '00']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -238,8 +239,8 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '9']], - [{ id: 'pr-b', token: '9' }], + [...reservationRows, ['pr-b', '00']], + [{ id: 'pr-b', token: '00' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -247,10 +248,10 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '9']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '00']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '9' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '00' }, ], ); const staleRebase = runCheck(); @@ -258,16 +259,16 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '00'); - const validBaselineRows = [...reservationRows, ['pr-a', '9']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]); + assert.equal(nextLayerStateToken(published), '01'); + const validBaselineRows = [...reservationRows, ['pr-a', '00']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '00' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 00/); + assert.match(helper.stdout, /pr-b: 01/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -275,26 +276,27 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () = ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '9'], ['pr-b', '00']], + [...reservationRows, ['pr-a', '00'], ['pr-b', '01']], [ - { id: 'pr-a', token: '9' }, - { id: 'pr-b', token: '00' }, + { id: 'pr-a', token: '00' }, + { id: 'pr-b', token: '01' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /36 published, 1 new/); + assert.match(corrected.stdout, /37 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]); + // News holds the last digit, 9: the ledger has no free digit left. + const digitRows = []; const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, ]); const beforeNine = { ...allDigits }; - delete beforeNine['retired-9']; + delete beforeNine.news; assert.equal( validateLayerStateAllocations(beforeNine, { ...beforeNine, @@ -340,10 +342,8 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'9'].map((digit) => [ - `qa-prior-digit-${digit}`, - digit, - ]); + // News holds the last digit: no prior digit to fill. + const priorDigits = []; const fixtureLayers = ['00', '01', '02', '03'].map((token) => ({ id: `qa-layer-${token}`, token, @@ -377,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 81); + assert.equal(params.get('l').length, 83); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -395,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 36); + assert.equal(Object.keys(published).length, 37); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 836112713..03c977426 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -18,6 +18,7 @@ ["military", "m"], ["military-awareness", "g"], ["military-installations", "i"], + ["news", "9"], ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 59cd4caa6..f779fc9e9 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,5 +1,23 @@ { "News feed unavailable": "ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ู…ุชุงุญ", + "Developing: {name}": "ุชุทูˆุฑุงุช: {name}", + "{count} events today": { + "zero": "{count} ุญุฏุซ ุงู„ูŠูˆู…", + "one": "ุญุฏุซ ูˆุงุญุฏ ุงู„ูŠูˆู… ({count})", + "two": "ุญุฏุซุงู† ุงู„ูŠูˆู… ({count})", + "few": "{count} ุฃุญุฏุงุซ ุงู„ูŠูˆู…", + "many": "{count} ุญุฏุซู‹ุง ุงู„ูŠูˆู…", + "other": "{count} ุญุฏุซ ุงู„ูŠูˆู…" + }, + "{count} reports today": { + "zero": "{count} ุชู‚ุฑูŠุฑ ุงู„ูŠูˆู…", + "one": "ุชู‚ุฑูŠุฑ ูˆุงุญุฏ ุงู„ูŠูˆู… ({count})", + "two": "ุชู‚ุฑูŠุฑุงู† ุงู„ูŠูˆู… ({count})", + "few": "{count} ุชู‚ุงุฑูŠุฑ ุงู„ูŠูˆู…", + "many": "{count} ุชู‚ุฑูŠุฑู‹ุง ุงู„ูŠูˆู…", + "other": "{count} ุชู‚ุฑูŠุฑ ุงู„ูŠูˆู…" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "ุขุฎุฑ 24 ุณุงุนุฉ ู…ู† ุจุคุฑ ู…ุฎุชุงุฑุฉ. ูŠุฑุชุจุท ูƒู„ ุชู‚ุฑูŠุฑ ุจู†ุงุดุฑู‡ุ› ุชูุณุชุฎุฑุฌ ุงู„ุฃู…ุงูƒู† ูˆุงู„ูุฆุงุช ู…ู† ุงู„ู†ุต ุชู„ู‚ุงุฆูŠู‹ุง ูˆู‚ุฏ ุชูƒูˆู† ุฎุงุทุฆุฉ. ุชุตู ุชุณู…ูŠุงุช ุงู„ุฃุทุฑุงู ุงู„ู…ุตุฏุฑุŒ ู„ุง ุตุญุฉ ุงู„ุชู‚ุฑูŠุฑ.", "Disputed: {name}": "ู…ู†ุทู‚ุฉ ู…ุชู†ุงุฒุน ุนู„ูŠู‡ุง: {name}", "Administered by {holder} ยท claimed by {claimant}": "ุชุฏูŠุฑู‡ุง {holder} ยท ุชุทุงู„ุจ ุจู‡ุง {claimant}", "Self-administered ยท claimed by {claimant}": "ุชุฏูŠุฑ ู†ูุณู‡ุง ยท ุชุทุงู„ุจ ุจู‡ุง {claimant}", @@ -55,6 +73,34 @@ "News feed not configured on this server": "ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ู…ู‡ูŠุฃ ุนู„ู‰ ู‡ุฐุง ุงู„ุฎุงุฏู…", "Malformed news hotspots response": "ุงุณุชุฌุงุจุฉ ุจุคุฑ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ุตุงู„ุญุฉ", "Malformed news feed response": "ุงุณุชุฌุงุจุฉ ู…ูˆุฌุฒ ุงู„ุฃุฎุจุงุฑ ุบูŠุฑ ุตุงู„ุญุฉ", + "{place} (placed by model)": "{place} (ุญุฏุฏู‡ ุงู„ู†ู…ูˆุฐุฌ)", + "English headline by the model": "ุนู†ูˆุงู† ุฅู†ุฌู„ูŠุฒูŠ ู…ู† ุงู„ู†ู…ูˆุฐุฌ", + "Not placed on the map": "ุบูŠุฑ ู…ูˆุถูˆุน ุนู„ู‰ ุงู„ุฎุฑูŠุทุฉ", + "Close": "ุฅุบู„ุงู‚", + "Any severity": "ุฃูŠ ุฏุฑุฌุฉ ุฎุทูˆุฑุฉ", + "{severity} and above": "{severity} ูุฃุนู„ู‰", + "{severity} only": "{severity} ูู‚ุท", + "All categories": "ูƒู„ ุงู„ูุฆุงุช", + "Reading the newsโ€ฆ": "ุฌุงุฑู ู‚ุฑุงุกุฉ ุงู„ุฃุฎุจุงุฑโ€ฆ", + "News, last 24 hours": "ุงู„ุฃุฎุจุงุฑุŒ ุขุฎุฑ 24 ุณุงุนุฉ", + "Last 24 h": "ุขุฎุฑ 24 ุณุงุนุฉ", + "Hotspots": "ุจุคุฑ ุณุงุฎู†ุฉ", + "Severity": "ุงู„ุฎุทูˆุฑุฉ", + "Category": "ุงู„ูุฆุฉ", + "Include minor news": "ุชุถู…ูŠู† ุงู„ุฃุฎุจุงุฑ ุงู„ุซุงู†ูˆูŠุฉ", + "Show event cards": "ุฅุธู‡ุงุฑ ุจุทุงู‚ุงุช ุงู„ุฃุญุฏุงุซ", + "In view": "ููŠ ู…ุฌุงู„ ุงู„ุฑุคูŠุฉ", + "Nothing reported near here in the last 24 h": "ู„ู… ูŠูุจู„ูŽู‘ุบ ุนู† ุดูŠุก ู‚ุฑูŠุจ ู…ู† ู‡ู†ุง ููŠ ุขุฎุฑ 24 ุณุงุนุฉ", + "Latest news": "ุขุฎุฑ ุงู„ุฃุฎุจุงุฑ", + "No news matches these filters in the last 24 h": "ู„ุง ุฃุฎุจุงุฑ ุชุทุงุจู‚ ู‡ุฐู‡ ุงู„ู…ุฑุดุญุงุช ููŠ ุขุฎุฑ 24 ุณุงุนุฉ", + "Load older news": "ุชุญู…ูŠู„ ุฃุฎุจุงุฑ ุฃู‚ุฏู…", + "How to read the map": "ูƒูŠู ุชู‚ุฑุฃ ุงู„ุฎุฑูŠุทุฉ", + "Colour: severity ยท icon: type of event": "ุงู„ู„ูˆู†: ุงู„ุฎุทูˆุฑุฉ ยท ุงู„ุฑู…ุฒ: ู†ูˆุน ุงู„ุญุฏุซ", + "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.": "ุชุตู ุชุณู…ูŠุงุช ุงู„ุฃุทุฑุงู ุงู„ู…ุตุฏุฑุŒ ู„ุง ุตุญุฉ ุงู„ุชู‚ุฑูŠุฑ.", + "Exit cockpit to fly to a news report": "ุงุฎุฑุฌ ู…ู† ุงู„ู‚ู…ุฑุฉ ู„ู„ุงู†ุชู‚ุงู„ ุฅู„ู‰ ุฎุจุฑ", + "News items in the feed": "ุนุฏุฏ ุงู„ุฃุฎุจุงุฑ ููŠ ุงู„ู…ูˆุฌุฒ", "NO PLACE LEFT BEHIND": "ู„ุง ู…ูƒุงู† ูŠูุชุฑูƒ ุฎู„ูู†ุง", "Initializing photorealistic world...": "ุฌุงุฑู ุชู‡ูŠุฆุฉ ุงู„ุนุงู„ู… ุงู„ูˆุงู‚ุนูŠโ€ฆ", "Configuring viewer...": "ุฌุงุฑู ุฅุนุฏุงุฏ ุงู„ุนุงุฑุถโ€ฆ", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9421361bc..43b913524 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,5 +1,15 @@ { "News feed unavailable": "Nachrichtenfeed nicht verfรผgbar", + "Developing: {name}": "In Entwicklung: {name}", + "{count} events today": { + "one": "{count} Ereignis heute", + "other": "{count} Ereignisse heute" + }, + "{count} reports today": { + "one": "{count} Meldung heute", + "other": "{count} Meldungen heute" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Letzte 24 h aus ausgewรคhlten Brennpunkten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus dem Text gelesen und kรถnnen falsch sein. Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Disputed: {name}": "Umstritten: {name}", "Administered by {holder} ยท claimed by {claimant}": "Verwaltet von {holder} ยท beansprucht von {claimant}", "Self-administered ยท claimed by {claimant}": "Selbstverwaltet ยท beansprucht von {claimant}", @@ -51,6 +61,34 @@ "News feed not configured on this server": "Nachrichtenfeed auf diesem Server nicht eingerichtet", "Malformed news hotspots response": "Fehlerhafte Antwort zu Nachrichten-Brennpunkten", "Malformed news feed response": "Fehlerhafte Antwort des Nachrichtenfeeds", + "{place} (placed by model)": "{place} (vom Modell verortet)", + "English headline by the model": "Englische Schlagzeile vom Modell", + "Not placed on the map": "Nicht auf der Karte verortet", + "Close": "SchlieรŸen", + "Any severity": "Jeder Schweregrad", + "{severity} and above": "{severity} und hรถher", + "{severity} only": "Nur {severity}", + "All categories": "Alle Kategorien", + "Reading the newsโ€ฆ": "Nachrichten werden gelesenโ€ฆ", + "News, last 24 hours": "Nachrichten der letzten 24 Stunden", + "Last 24 h": "Letzte 24 Std.", + "Hotspots": "Brennpunkte", + "Severity": "Schweregrad", + "Category": "Kategorie", + "Include minor news": "Nebensรคchliche Meldungen einbeziehen", + "Show event cards": "Ereigniskarten zeigen", + "In view": "Im Blick", + "Nothing reported near here in the last 24 h": "In den letzten 24 h nichts in der Nรคhe gemeldet", + "Latest news": "Neueste Nachrichten", + "No news matches these filters in the last 24 h": "Keine Nachrichten der letzten 24 h passen zu diesen Filtern", + "Load older news": "ร„ltere Nachrichten laden", + "How to read the map": "So liest man die Karte", + "Colour: severity ยท icon: type of event": "Farbe: Schweregrad ยท Symbol: Art des Ereignisses", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one sideโ€™s own press.": "Durchgehender Rand: von beiden Seiten oder unabhรคngig gemeldet. Gestrichelter Rand: eine Behauptung. Dรผnner Rand: eine Quelle oder die eigene Presse einer Seite.", + "Larger: more sources ยท fainter: older. Picture card: a major or fast-growing event with two or more sources.": "GrรถรŸer: mehr Quellen ยท blasser: รคlter. Bildkarte: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", + "Side labels describe the source, not whether a report is true.": "Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Exit cockpit to fly to a news report": "Cockpit verlassen, um zu einer Meldung zu fliegen", + "News items in the feed": "Meldungen im Feed", "Flying to Austin, TX...": "Flug nach Austin, TX...", "Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...", "Configuring viewer...": "Viewer wird konfiguriert...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 914a85171..ba4fa99a9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Noticias no disponibles", + "Developing: {name}": "En desarrollo: {name}", + "{count} events today": { + "one": "{count} suceso hoy", + "many": "{count} sucesos hoy", + "other": "{count} sucesos hoy" + }, + "{count} reports today": { + "one": "{count} noticia hoy", + "many": "{count} noticias hoy", + "other": "{count} noticias hoy" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "รšltimas 24 h de focos seleccionados. Cada noticia enlaza a su medio; los lugares y categorรญas se leen del texto automรกticamente y pueden ser errรณneos. Las etiquetas de bando describen la fuente, no si la noticia es cierta.", "Disputed: {name}": "En disputa: {name}", "Administered by {holder} ยท claimed by {claimant}": "Administrado por {holder} ยท reclamado por {claimant}", "Self-administered ยท claimed by {claimant}": "Autogobernado ยท reclamado por {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Noticias no configuradas en este servidor", "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", "Malformed news feed response": "Respuesta de noticias mal formada", + "{place} (placed by model)": "{place} (ubicado por el modelo)", + "English headline by the model": "Titular en inglรฉs del modelo", + "Not placed on the map": "Sin ubicar en el mapa", + "Close": "Cerrar", + "Any severity": "Cualquier gravedad", + "{severity} and above": "{severity} o mรกs", + "{severity} only": "Solo {severity}", + "All categories": "Todas las categorรญas", + "Reading the newsโ€ฆ": "Leyendo las noticiasโ€ฆ", + "News, last 24 hours": "Noticias de las รบltimas 24 horas", + "Last 24 h": "รšltimas 24 h", + "Hotspots": "Focos", + "Severity": "Gravedad", + "Category": "Categorรญa", + "Include minor news": "Incluir noticias menores", + "Show event cards": "Mostrar tarjetas de sucesos", + "In view": "A la vista", + "Nothing reported near here in the last 24 h": "Nada informado cerca de aquรญ en las รบltimas 24 h", + "Latest news": "รšltimas noticias", + "No news matches these filters in the last 24 h": "Ninguna noticia de las รบltimas 24 h coincide con estos filtros", + "Load older news": "Cargar noticias anteriores", + "How to read the map": "Cรณmo leer el mapa", + "Colour: severity ยท icon: type of event": "Color: gravedad ยท icono: tipo de suceso", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one sideโ€™s own press.": "Borde continuo: informado por ambos bandos o de forma independiente. Borde discontinuo: una afirmaciรณn. Borde fino: una fuente, o la prensa de un bando.", + "Larger: more sources ยท fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Mรกs grande: mรกs fuentes ยท mรกs tenue: mรกs antiguo. Tarjeta con imagen: un suceso grave o en rรกpido crecimiento con dos o mรกs fuentes.", + "Side labels describe the source, not whether a report is true.": "Las etiquetas de bando describen la fuente, no si la noticia es cierta.", + "Exit cockpit to fly to a news report": "Sal de la cabina para volar a una noticia", + "News items in the feed": "Noticias en la lista", "NO PLACE LEFT BEHIND": "NINGรšN RINCร“N SIN VIGILAR", "Initializing photorealistic world...": "Inicializando el mundo fotorrealista...", "Configuring viewer...": "Configurando el visor...", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f01ca2956..aae0bbb7d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Fil dโ€™actualitรฉs indisponible", + "Developing: {name}": "En cours : {name}", + "{count} events today": { + "one": "{count} รฉvรฉnement aujourdโ€™hui", + "many": "{count} รฉvรฉnements aujourdโ€™hui", + "other": "{count} รฉvรฉnements aujourdโ€™hui" + }, + "{count} reports today": { + "one": "{count} article aujourdโ€™hui", + "many": "{count} articles aujourdโ€™hui", + "other": "{count} articles aujourdโ€™hui" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Derniรจres 24 h des foyers retenus. Chaque article renvoie ร  son รฉditeur ; lieux et catรฉgories sont lus automatiquement dans le texte et peuvent รชtre faux. Les รฉtiquettes de camp dรฉcrivent la source, pas la vรฉracitรฉ de lโ€™article.", "Disputed: {name}": "Contestรฉ : {name}", "Administered by {holder} ยท claimed by {claimant}": "Administrรฉ par {holder} ยท revendiquรฉ par {claimant}", "Self-administered ยท claimed by {claimant}": "Autoadministrรฉ ยท revendiquรฉ par {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Fil dโ€™actualitรฉs non configurรฉ sur ce serveur", "Malformed news hotspots response": "Rรฉponse des foyers dโ€™actualitรฉ mal formรฉe", "Malformed news feed response": "Rรฉponse du fil dโ€™actualitรฉs mal formรฉe", + "{place} (placed by model)": "{place} (placรฉ par le modรจle)", + "English headline by the model": "Titre anglais du modรจle", + "Not placed on the map": "Non placรฉ sur la carte", + "Close": "Fermer", + "Any severity": "Toute gravitรฉ", + "{severity} and above": "{severity} et plus", + "{severity} only": "{severity} uniquement", + "All categories": "Toutes les catรฉgories", + "Reading the newsโ€ฆ": "Lecture des actualitรฉsโ€ฆ", + "News, last 24 hours": "Actualitรฉs des derniรจres 24 heures", + "Last 24 h": "Derniรจres 24 h", + "Hotspots": "Foyers", + "Severity": "Gravitรฉ", + "Category": "Catรฉgorie", + "Include minor news": "Inclure les actualitรฉs secondaires", + "Show event cards": "Afficher les cartes dโ€™รฉvรฉnement", + "In view": "Dans la vue", + "Nothing reported near here in the last 24 h": "Rien de signalรฉ prรจs dโ€™ici ces derniรจres 24 h", + "Latest news": "Derniรจres actualitรฉs", + "No news matches these filters in the last 24 h": "Aucune actualitรฉ des derniรจres 24 h ne correspond ร  ces filtres", + "Load older news": "Charger des actualitรฉs plus anciennes", + "How to read the map": "Comment lire la carte", + "Colour: severity ยท icon: type of event": "Couleur : gravitรฉ ยท icรดne : type dโ€™รฉvรฉnement", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one sideโ€™s own press.": "Bord plein : rapportรฉ par les deux camps ou de faรงon indรฉpendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse dโ€™un camp.", + "Larger: more sources ยท fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources ยท plus pรขle : plus ancien. Carte illustrรฉe : un รฉvรฉnement majeur ou en forte croissance, avec au moins deux sources.", + "Side labels describe the source, not whether a report is true.": "Les รฉtiquettes de camp dรฉcrivent la source, pas la vรฉracitรฉ de lโ€™article.", + "Exit cockpit to fly to a news report": "Quittez le cockpit pour aller ร  un article", + "News items in the feed": "Articles dans le fil", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'รฉpingle, Esc pour annuler.", "Click where the pin goes.": "Cliquez ร  l'endroit oรน poser l'รฉpingle.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index dcaf4855c..cb9c27098 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Feed delle notizie non disponibile", + "Developing: {name}": "In sviluppo: {name}", + "{count} events today": { + "one": "{count} evento oggi", + "many": "{count} eventi oggi", + "other": "{count} eventi oggi" + }, + "{count} reports today": { + "one": "{count} notizia oggi", + "many": "{count} notizie oggi", + "other": "{count} notizie oggi" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "Ultime 24 h dai focolai selezionati. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e possono essere errati. Le etichette di parte descrivono la fonte, non se la notizia รจ vera.", "Disputed: {name}": "Conteso: {name}", "Administered by {holder} ยท claimed by {claimant}": "Amministrato da {holder} ยท rivendicato da {claimant}", "Self-administered ยท claimed by {claimant}": "Autogovernato ยท rivendicato da {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Feed delle notizie non configurato su questo server", "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", "Malformed news feed response": "Risposta del feed delle notizie non valida", + "{place} (placed by model)": "{place} (collocato dal modello)", + "English headline by the model": "Titolo inglese del modello", + "Not placed on the map": "Non collocato sulla mappa", + "Close": "Chiudi", + "Any severity": "Qualsiasi gravitร ", + "{severity} and above": "{severity} e oltre", + "{severity} only": "Solo {severity}", + "All categories": "Tutte le categorie", + "Reading the newsโ€ฆ": "Lettura delle notizieโ€ฆ", + "News, last 24 hours": "Notizie delle ultime 24 ore", + "Last 24 h": "Ultime 24 h", + "Hotspots": "Focolai", + "Severity": "Gravitร ", + "Category": "Categoria", + "Include minor news": "Includi notizie minori", + "Show event cards": "Mostra le schede degli eventi", + "In view": "In vista", + "Nothing reported near here in the last 24 h": "Nulla segnalato qui vicino nelle ultime 24 h", + "Latest news": "Ultime notizie", + "No news matches these filters in the last 24 h": "Nessuna notizia delle ultime 24 h corrisponde a questi filtri", + "Load older news": "Carica notizie precedenti", + "How to read the map": "Come leggere la mappa", + "Colour: severity ยท icon: type of event": "Colore: gravitร  ยท icona: tipo di evento", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one sideโ€™s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: unโ€™affermazione. Bordo sottile: una fonte, o la stampa di una parte.", + "Larger: more sources ยท fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Piรน grande: piรน fonti ยท piรน tenue: piรน vecchio. Scheda con immagine: un evento grave o in rapida crescita con due o piรน fonti.", + "Side labels describe the source, not whether a report is true.": "Le etichette di parte descrivono la fonte, non se la notizia รจ vera.", + "Exit cockpit to fly to a news report": "Esci dalla cabina per volare a una notizia", + "News items in the feed": "Notizie nel feed", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", "Click where the pin goes.": "Fai clic dove va il segnaposto.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 743fcb71c..fc62c1703 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,5 +1,13 @@ { "News feed unavailable": "ใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใ‚’ๅˆฉ็”จใงใใพใ›ใ‚“", + "Developing: {name}": "้€ฒ่กŒไธญ: {name}", + "{count} events today": { + "other": "ๆœฌๆ—ฅใฎๅ‡บๆฅไบ‹ {count}ไปถ" + }, + "{count} reports today": { + "other": "ๆœฌๆ—ฅใฎๅ ฑ้“ {count}ไปถ" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "ๅŽณ้ธใ—ใŸใƒ›ใƒƒใƒˆใ‚นใƒใƒƒใƒˆใฎ็›ด่ฟ‘24ๆ™‚้–“ใ€‚ๅ„ๅ ฑ้“ใฏ็™บไฟกๅ…ƒใซใƒชใƒณใ‚ฏใ—ใพใ™ใ€‚ๅ ดๆ‰€ใจใ‚ซใƒ†ใ‚ดใƒชใฏๆœฌๆ–‡ใ‹ใ‚‰่‡ชๅ‹•ใง่ชญใฟๅ–ใ‚‹ใŸใ‚่ชคใ‚ŠใŒใ‚ใ‚Šๅพ—ใพใ™ใ€‚้™ฃๅ–ถใƒฉใƒ™ใƒซใฏๆƒ…ๅ ฑๆบใ‚’็คบใ™ใ‚‚ใฎใงใ€ๅ ฑ้“ใฎ็œŸๅฝใงใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚", "Disputed: {name}": "ไฟ‚ไบ‰ๅœฐ: {name}", "Administered by {holder} ยท claimed by {claimant}": "{holder}ใŒๆ–ฝๆ”ฟ ยท {claimant}ใŒ้ ˜ๆœ‰ใ‚’ไธปๅผต", "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ใŒ้ ˜ๆœ‰ใ‚’ไธปๅผต", @@ -50,6 +58,34 @@ "News feed not configured on this server": "ใ“ใฎใ‚ตใƒผใƒใƒผใงใฏใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใŒ่จญๅฎšใ•ใ‚Œใฆใ„ใพใ›ใ‚“", "Malformed news hotspots response": "ใƒ‹ใƒฅใƒผใ‚นใฎใƒ›ใƒƒใƒˆใ‚นใƒใƒƒใƒˆๅฟœ็ญ”ใฎๅฝขๅผใŒไธๆญฃใงใ™", "Malformed news feed response": "ใƒ‹ใƒฅใƒผใ‚นใƒ•ใ‚ฃใƒผใƒ‰ใฎๅฟœ็ญ”ใฎๅฝขๅผใŒไธๆญฃใงใ™", + "{place} (placed by model)": "{place}๏ผˆใƒขใƒ‡ใƒซใŒๆŽจๅฎš๏ผ‰", + "English headline by the model": "ใƒขใƒ‡ใƒซใซใ‚ˆใ‚‹่‹ฑ่ชžใฎ่ฆ‹ๅ‡บใ—", + "Not placed on the map": "ๅœฐๅ›ณไธŠใซ้…็ฝฎใ•ใ‚Œใฆใ„ใพใ›ใ‚“", + "Close": "้–‰ใ˜ใ‚‹", + "Any severity": "ใ™ในใฆใฎๆทฑๅˆปๅบฆ", + "{severity} and above": "{severity}ไปฅไธŠ", + "{severity} only": "{severity}ใฎใฟ", + "All categories": "ใ™ในใฆใฎใ‚ซใƒ†ใ‚ดใƒช", + "Reading the newsโ€ฆ": "ใƒ‹ใƒฅใƒผใ‚นใ‚’่ชญใฟ่พผใฟไธญโ€ฆ", + "News, last 24 hours": "็›ด่ฟ‘24ๆ™‚้–“ใฎใƒ‹ใƒฅใƒผใ‚น", + "Last 24 h": "็›ด่ฟ‘24ๆ™‚้–“", + "Hotspots": "ใƒ›ใƒƒใƒˆใ‚นใƒใƒƒใƒˆ", + "Severity": "ๆทฑๅˆปๅบฆ", + "Category": "ใ‚ซใƒ†ใ‚ดใƒช", + "Include minor news": "่ปฝๅพฎใชใƒ‹ใƒฅใƒผใ‚นใ‚‚ๅซใ‚ใ‚‹", + "Show event cards": "ใ‚คใƒ™ใƒณใƒˆใ‚ซใƒผใƒ‰ใ‚’่กจ็คบ", + "In view": "่กจ็คบ็ฏ„ๅ›ฒๅ†…", + "Nothing reported near here in the last 24 h": "็›ด่ฟ‘24ๆ™‚้–“ใ€ใ“ใฎไป˜่ฟ‘ใฎๅ ฑ้“ใฏใ‚ใ‚Šใพใ›ใ‚“", + "Latest news": "ๆœ€ๆ–ฐใƒ‹ใƒฅใƒผใ‚น", + "No news matches these filters in the last 24 h": "็›ด่ฟ‘24ๆ™‚้–“ใงๆกไปถใซๅˆใ†ใƒ‹ใƒฅใƒผใ‚นใฏใ‚ใ‚Šใพใ›ใ‚“", + "Load older news": "ไปฅๅ‰ใฎใƒ‹ใƒฅใƒผใ‚นใ‚’่ชญใฟ่พผใ‚€", + "How to read the map": "ๅœฐๅ›ณใฎ่ฆ‹ๆ–น", + "Colour: severity ยท icon: type of event": "่‰ฒ: ๆทฑๅˆปๅบฆ ยท ใ‚ขใ‚คใ‚ณใƒณ: ๅ‡บๆฅไบ‹ใฎ็จฎ้กž", + "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.": "ๅคงใใ„: ๆƒ…ๅ ฑๆบใŒๅคšใ„ ยท ่–„ใ„: ๅคใ„ใ€‚็”ปๅƒใ‚ซใƒผใƒ‰: ๆƒ…ๅ ฑๆบใŒ2ไปถไปฅไธŠใ‚ใ‚‹ๆทฑๅˆปใชใ€ใพใŸใฏๆ€ฅๆ‹กๅคงไธญใฎๅ‡บๆฅไบ‹ใ€‚", + "Side labels describe the source, not whether a report is true.": "้™ฃๅ–ถใƒฉใƒ™ใƒซใฏๆƒ…ๅ ฑๆบใ‚’็คบใ™ใ‚‚ใฎใงใ€ๅ ฑ้“ใฎ็œŸๅฝใงใฏใ‚ใ‚Šใพใ›ใ‚“ใ€‚", + "Exit cockpit to fly to a news report": "ใƒ‹ใƒฅใƒผใ‚นใธ็งปๅ‹•ใ™ใ‚‹ใซใฏใ‚ณใƒƒใ‚ฏใƒ”ใƒƒใƒˆใ‚’็ต‚ไบ†ใ—ใฆใใ ใ•ใ„", + "News items in the feed": "ใƒ•ใ‚ฃใƒผใƒ‰ๅ†…ใฎใƒ‹ใƒฅใƒผใ‚นๆ•ฐ", "Pick a shape, then click the map.": "ๅ›ณๅฝขใ‚’้ธใ‚“ใงใ‹ใ‚‰ใ€ๅœฐๅ›ณใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใใ ใ•ใ„ใ€‚", "Enter to place the pin, Esc to cancel.": "Enterใงใƒ”ใƒณใ‚’้…็ฝฎใ€Escใงใ‚ญใƒฃใƒณใ‚ปใƒซใ€‚", "Click where the pin goes.": "ใƒ”ใƒณใ‚’็ฝฎใๅ ดๆ‰€ใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใใ ใ•ใ„ใ€‚", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 2068c08b6..a5da05da8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1,5 +1,17 @@ { "News feed unavailable": "Feed de notรญcias indisponรญvel", + "Developing: {name}": "Em desenvolvimento: {name}", + "{count} events today": { + "one": "{count} evento hoje", + "many": "{count} eventos hoje", + "other": "{count} eventos hoje" + }, + "{count} reports today": { + "one": "{count} notรญcia hoje", + "many": "{count} notรญcias hoje", + "other": "{count} notรญcias hoje" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "รšltimas 24 h dos focos selecionados. Cada notรญcia leva ao seu editor; locais e categorias sรฃo lidos do texto automaticamente e podem estar errados. Os rรณtulos de lado descrevem a fonte, nรฃo se a notรญcia รฉ verdadeira.", "Disputed: {name}": "Em disputa: {name}", "Administered by {holder} ยท claimed by {claimant}": "Administrado por {holder} ยท reivindicado por {claimant}", "Self-administered ยท claimed by {claimant}": "Autogovernado ยท reivindicado por {claimant}", @@ -52,6 +64,34 @@ "News feed not configured on this server": "Feed de notรญcias nรฃo configurado neste servidor", "Malformed news hotspots response": "Resposta de focos de notรญcias malformada", "Malformed news feed response": "Resposta do feed de notรญcias malformada", + "{place} (placed by model)": "{place} (localizado pelo modelo)", + "English headline by the model": "Manchete em inglรชs do modelo", + "Not placed on the map": "Nรฃo localizado no mapa", + "Close": "Fechar", + "Any severity": "Qualquer gravidade", + "{severity} and above": "{severity} ou mais", + "{severity} only": "Apenas {severity}", + "All categories": "Todas as categorias", + "Reading the newsโ€ฆ": "Lendo as notรญciasโ€ฆ", + "News, last 24 hours": "Notรญcias das รบltimas 24 horas", + "Last 24 h": "รšltimas 24 h", + "Hotspots": "Focos", + "Severity": "Gravidade", + "Category": "Categoria", + "Include minor news": "Incluir notรญcias menores", + "Show event cards": "Mostrar cartรตes de eventos", + "In view": "Na vista", + "Nothing reported near here in the last 24 h": "Nada noticiado aqui perto nas รบltimas 24 h", + "Latest news": "รšltimas notรญcias", + "No news matches these filters in the last 24 h": "Nenhuma notรญcia das รบltimas 24 h corresponde a estes filtros", + "Load older news": "Carregar notรญcias anteriores", + "How to read the map": "Como ler o mapa", + "Colour: severity ยท icon: type of event": "Cor: gravidade ยท รญcone: tipo de evento", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one sideโ€™s own press.": "Borda contรญnua: noticiado por ambos os lados ou de forma independente. Borda tracejada: uma alegaรงรฃo. Borda fina: uma fonte, ou a imprensa de um lado.", + "Larger: more sources ยท fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Maior: mais fontes ยท mais tรชnue: mais antigo. Cartรฃo com imagem: um evento grave ou em rรกpido crescimento com duas ou mais fontes.", + "Side labels describe the source, not whether a report is true.": "Os rรณtulos de lado descrevem a fonte, nรฃo se a notรญcia รฉ verdadeira.", + "Exit cockpit to fly to a news report": "Saia da cabine para voar atรฉ uma notรญcia", + "News items in the feed": "Notรญcias no feed", "Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.", "Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.", "Click where the pin goes.": "Clique onde o marcador deve ficar.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e6df84b68..2725ef272 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,5 +1,19 @@ { "News feed unavailable": "ะ›ะตะฝั‚ะฐ ะฝะพะฒะพัั‚ะตะน ะฝะตะดะพัั‚ัƒะฟะฝะฐ", + "Developing: {name}": "ะ ะฐะทะฒะธะฒะฐะตั‚ัั: {name}", + "{count} events today": { + "one": "{count} ัะพะฑั‹ั‚ะธะต ัะตะณะพะดะฝั", + "few": "{count} ัะพะฑั‹ั‚ะธั ัะตะณะพะดะฝั", + "many": "{count} ัะพะฑั‹ั‚ะธะน ัะตะณะพะดะฝั", + "other": "{count} ัะพะฑั‹ั‚ะธั ัะตะณะพะดะฝั" + }, + "{count} reports today": { + "one": "{count} ัะพะพะฑั‰ะตะฝะธะต ัะตะณะพะดะฝั", + "few": "{count} ัะพะพะฑั‰ะตะฝะธั ัะตะณะพะดะฝั", + "many": "{count} ัะพะพะฑั‰ะตะฝะธะน ัะตะณะพะดะฝั", + "other": "{count} ัะพะพะฑั‰ะตะฝะธั ัะตะณะพะดะฝั" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "ะŸะพัะปะตะดะฝะธะต 24 ั‡ ะฟะพ ะฒั‹ะฑั€ะฐะฝะฝั‹ะผ ะณะพั€ัั‡ะธะผ ั‚ะพั‡ะบะฐะผ. ะšะฐะถะดะพะต ัะพะพะฑั‰ะตะฝะธะต ะฒะตะดั‘ั‚ ะบ ัะฒะพะตะผัƒ ะธะทะดะฐะฝะธัŽ; ะผะตัั‚ะฐ ะธ ะบะฐั‚ะตะณะพั€ะธะธ ะพะฟั€ะตะดะตะปััŽั‚ัั ะฟะพ ั‚ะตะบัั‚ัƒ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะธ ะผะพะณัƒั‚ ะฑั‹ั‚ัŒ ะพัˆะธะฑะพั‡ะฝั‹. ะœะตั‚ะบะธ ัั‚ะพั€ะพะฝ ะพะฟะธัั‹ะฒะฐัŽั‚ ะธัั‚ะพั‡ะฝะธะบ, ะฐ ะฝะต ะดะพัั‚ะพะฒะตั€ะฝะพัั‚ัŒ ัะพะพะฑั‰ะตะฝะธั.", "Disputed: {name}": "ะกะฟะพั€ะฝะฐั ั‚ะตั€ั€ะธั‚ะพั€ะธั: {name}", "Administered by {holder} ยท claimed by {claimant}": "ะŸะพะด ัƒะฟั€ะฐะฒะปะตะฝะธะตะผ: {holder} ยท ะฟั€ะตั‚ะตะฝะดัƒะตั‚: {claimant}", "Self-administered ยท claimed by {claimant}": "ะกะฐะผะพัƒะฟั€ะฐะฒะปัะตะผะฐั ยท ะฟั€ะตั‚ะตะฝะดัƒะตั‚: {claimant}", @@ -53,6 +67,34 @@ "News feed not configured on this server": "ะ›ะตะฝั‚ะฐ ะฝะพะฒะพัั‚ะตะน ะฝะต ะฝะฐัั‚ั€ะพะตะฝะฐ ะฝะฐ ัั‚ะพะผ ัะตั€ะฒะตั€ะต", "Malformed news hotspots response": "ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะณะพั€ัั‡ะธั… ั‚ะพั‡ะตะบ ะฝะพะฒะพัั‚ะตะน", "Malformed news feed response": "ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะปะตะฝั‚ั‹ ะฝะพะฒะพัั‚ะตะน", + "{place} (placed by model)": "{place} (ะผะตัั‚ะพ ะพะฟั€ะตะดะตะปะธะปะฐ ะผะพะดะตะปัŒ)", + "English headline by the model": "ะะฝะณะปะธะนัะบะธะน ะทะฐะณะพะปะพะฒะพะบ ะพั‚ ะผะพะดะตะปะธ", + "Not placed on the map": "ะะต ะพั‚ะผะตั‡ะตะฝะพ ะฝะฐ ะบะฐั€ั‚ะต", + "Close": "ะ—ะฐะบั€ั‹ั‚ัŒ", + "Any severity": "ะ›ัŽะฑะฐั ั‚ัะถะตัั‚ัŒ", + "{severity} and above": "{severity} ะธ ะฒั‹ัˆะต", + "{severity} only": "ะขะพะปัŒะบะพ {severity}", + "All categories": "ะ’ัะต ะบะฐั‚ะตะณะพั€ะธะธ", + "Reading the newsโ€ฆ": "ะงะธั‚ะฐะตะผ ะฝะพะฒะพัั‚ะธโ€ฆ", + "News, last 24 hours": "ะะพะฒะพัั‚ะธ ะทะฐ ะฟะพัะปะตะดะฝะธะต 24 ั‡ะฐัะฐ", + "Last 24 h": "ะ—ะฐ 24 ั‡", + "Hotspots": "ะ“ะพั€ัั‡ะธะต ั‚ะพั‡ะบะธ", + "Severity": "ะขัะถะตัั‚ัŒ", + "Category": "ะšะฐั‚ะตะณะพั€ะธั", + "Include minor news": "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะฒั‚ะพั€ะพัั‚ะตะฟะตะฝะฝั‹ะต ะฝะพะฒะพัั‚ะธ", + "Show event cards": "ะŸะพะบะฐะทั‹ะฒะฐั‚ัŒ ะบะฐั€ั‚ะพั‡ะบะธ ัะพะฑั‹ั‚ะธะน", + "In view": "ะ’ ะฟะพะปะต ะทั€ะตะฝะธั", + "Nothing reported near here in the last 24 h": "ะ—ะฐ ะฟะพัะปะตะดะฝะธะต 24 ั‡ ะฟะพะฑะปะธะทะพัั‚ะธ ะฝะธั‡ะตะณะพ ะฝะต ัะพะพะฑั‰ะฐะปะพััŒ", + "Latest news": "ะŸะพัะปะตะดะฝะธะต ะฝะพะฒะพัั‚ะธ", + "No news matches these filters in the last 24 h": "ะ—ะฐ ะฟะพัะปะตะดะฝะธะต 24 ั‡ ะฝะตั‚ ะฝะพะฒะพัั‚ะตะน ะฟะพ ัั‚ะธะผ ั„ะธะปัŒั‚ั€ะฐะผ", + "Load older news": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ ะฑะพะปะตะต ั€ะฐะฝะฝะธะต ะฝะพะฒะพัั‚ะธ", + "How to read the map": "ะšะฐะบ ั‡ะธั‚ะฐั‚ัŒ ะบะฐั€ั‚ัƒ", + "Colour: severity ยท icon: type of event": "ะฆะฒะตั‚: ั‚ัะถะตัั‚ัŒ ยท ะทะฝะฐั‡ะพะบ: ั‚ะธะฟ ัะพะฑั‹ั‚ะธั", + "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.": "ะœะตั‚ะบะธ ัั‚ะพั€ะพะฝ ะพะฟะธัั‹ะฒะฐัŽั‚ ะธัั‚ะพั‡ะฝะธะบ, ะฐ ะฝะต ะดะพัั‚ะพะฒะตั€ะฝะพัั‚ัŒ ัะพะพะฑั‰ะตะฝะธั.", + "Exit cockpit to fly to a news report": "ะ’ั‹ะนะดะธั‚ะต ะธะท ะบะฐะฑะธะฝั‹, ั‡ั‚ะพะฑั‹ ะฟะตั€ะตะปะตั‚ะตั‚ัŒ ะบ ัะพะพะฑั‰ะตะฝะธัŽ", + "News items in the feed": "ะะพะฒะพัั‚ะตะน ะฒ ะปะตะฝั‚ะต", "Flying to Austin, TX...": "ะŸะตั€ะตะปั‘ั‚ ะฒ ะžัั‚ะธะฝ, ะขะตั…ะฐั...", "Restoring shared view...": "ะ’ะพััั‚ะฐะฝะพะฒะปะตะฝะธะต ะพะฑั‰ะตะณะพ ะฒะธะดะฐ...", "Initializing systems...": "ะ˜ะฝะธั†ะธะฐะปะธะทะฐั†ะธั ัะธัั‚ะตะผ...", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 41f6f1207..77aa0f593 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,5 +1,19 @@ { "News feed unavailable": "ะกั‚ั€ั–ั‡ะบะฐ ะฝะพะฒะธะฝ ะฝะตะดะพัั‚ัƒะฟะฝะฐ", + "Developing: {name}": "ะ ะพะทะฒะธะฒะฐั”ั‚ัŒัั: {name}", + "{count} events today": { + "one": "{count} ะฟะพะดั–ั ััŒะพะณะพะดะฝั–", + "few": "{count} ะฟะพะดั–ั— ััŒะพะณะพะดะฝั–", + "many": "{count} ะฟะพะดั–ะน ััŒะพะณะพะดะฝั–", + "other": "{count} ะฟะพะดั–ั— ััŒะพะณะพะดะฝั–" + }, + "{count} reports today": { + "one": "{count} ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ััŒะพะณะพะดะฝั–", + "few": "{count} ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ััŒะพะณะพะดะฝั–", + "many": "{count} ะฟะพะฒั–ะดะพะผะปะตะฝัŒ ััŒะพะณะพะดะฝั–", + "other": "{count} ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ััŒะพะณะพะดะฝั–" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "ะžัั‚ะฐะฝะฝั– 24 ะณะพะด ะทะฐ ะฒะธะฑั€ะฐะฝะธะผะธ ะณะฐั€ัั‡ะธะผะธ ั‚ะพั‡ะบะฐะผะธ. ะšะพะถะฝะต ะฟะพะฒั–ะดะพะผะปะตะฝะฝั ะฒะตะดะต ะดะพ ัะฒะพะณะพ ะฒะธะดะฐะฝะฝั; ะผั–ัั†ั ะน ะบะฐั‚ะตะณะพั€ั–ั— ะฒะธะทะฝะฐั‡ะฐัŽั‚ัŒัั ะท ั‚ะตะบัั‚ัƒ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะฝะพ ั– ะผะพะถัƒั‚ัŒ ะฑัƒั‚ะธ ั…ะธะฑะฝะธะผะธ. ะŸะพะทะฝะฐั‡ะบะธ ัั‚ะพั€ั–ะฝ ะพะฟะธััƒัŽั‚ัŒ ะดะถะตั€ะตะปะพ, ะฐ ะฝะต ะฟั€ะฐะฒะดะธะฒั–ัั‚ัŒ ะฟะพะฒั–ะดะพะผะปะตะฝะฝั.", "Disputed: {name}": "ะกะฟั–ั€ะฝะฐ ั‚ะตั€ะธั‚ะพั€ั–ั: {name}", "Administered by {holder} ยท claimed by {claimant}": "ะŸั–ะด ัƒะฟั€ะฐะฒะปั–ะฝะฝัะผ: {holder} ยท ะฟั€ะตั‚ะตะฝะดัƒั”: {claimant}", "Self-administered ยท claimed by {claimant}": "ะกะฐะผะพะฒั€ัะดะฝะฐ ยท ะฟั€ะตั‚ะตะฝะดัƒั”: {claimant}", @@ -53,6 +67,34 @@ "News feed not configured on this server": "ะกั‚ั€ั–ั‡ะบัƒ ะฝะพะฒะธะฝ ะฝะต ะฝะฐะปะฐัˆั‚ะพะฒะฐะฝะพ ะฝะฐ ั†ัŒะพะผัƒ ัะตั€ะฒะตั€ั–", "Malformed news hotspots response": "ะะตะบะพั€ะตะบั‚ะฝะฐ ะฒั–ะดะฟะพะฒั–ะดัŒ ะณะฐั€ัั‡ะธั… ั‚ะพั‡ะพะบ ะฝะพะฒะธะฝ", "Malformed news feed response": "ะะตะบะพั€ะตะบั‚ะฝะฐ ะฒั–ะดะฟะพะฒั–ะดัŒ ัั‚ั€ั–ั‡ะบะธ ะฝะพะฒะธะฝ", + "{place} (placed by model)": "{place} (ะผั–ัั†ะต ะฒะธะทะฝะฐั‡ะธะปะฐ ะผะพะดะตะปัŒ)", + "English headline by the model": "ะะฝะณะปั–ะนััŒะบะธะน ะทะฐะณะพะปะพะฒะพะบ ะฒั–ะด ะผะพะดะตะปั–", + "Not placed on the map": "ะะต ะฟะพะทะฝะฐั‡ะตะฝะพ ะฝะฐ ะผะฐะฟั–", + "Close": "ะ—ะฐะบั€ะธั‚ะธ", + "Any severity": "ะ‘ัƒะดัŒ-ัะบะฐ ั‚ัะถะบั–ัั‚ัŒ", + "{severity} and above": "{severity} ั– ะฒะธั‰ะต", + "{severity} only": "ะ›ะธัˆะต {severity}", + "All categories": "ะฃัั– ะบะฐั‚ะตะณะพั€ั–ั—", + "Reading the newsโ€ฆ": "ะงะธั‚ะฐั”ะผะพ ะฝะพะฒะธะฝะธโ€ฆ", + "News, last 24 hours": "ะะพะฒะธะฝะธ ะทะฐ ะพัั‚ะฐะฝะฝั– 24 ะณะพะดะธะฝะธ", + "Last 24 h": "ะ—ะฐ 24 ะณะพะด", + "Hotspots": "ะ“ะฐั€ัั‡ั– ั‚ะพั‡ะบะธ", + "Severity": "ะขัะถะบั–ัั‚ัŒ", + "Category": "ะšะฐั‚ะตะณะพั€ั–ั", + "Include minor news": "ะŸะพะบะฐะทัƒะฒะฐั‚ะธ ะดั€ัƒะณะพั€ัะดะฝั– ะฝะพะฒะธะฝะธ", + "Show event cards": "ะŸะพะบะฐะทัƒะฒะฐั‚ะธ ะบะฐั€ั‚ะบะธ ะฟะพะดั–ะน", + "In view": "ะฃ ะฟะพะปั– ะทะพั€ัƒ", + "Nothing reported near here in the last 24 h": "ะ—ะฐ ะพัั‚ะฐะฝะฝั– 24 ะณะพะด ะฟะพะฑะปะธะทัƒ ะฝั–ั‡ะพะณะพ ะฝะต ะฟะพะฒั–ะดะพะผะปัะปะพัั", + "Latest news": "ะžัั‚ะฐะฝะฝั– ะฝะพะฒะธะฝะธ", + "No news matches these filters in the last 24 h": "ะ—ะฐ ะพัั‚ะฐะฝะฝั– 24 ะณะพะด ะฝะตะผะฐั” ะฝะพะฒะธะฝ ะทะฐ ั†ะธะผะธ ั„ั–ะปัŒั‚ั€ะฐะผะธ", + "Load older news": "ะ—ะฐะฒะฐะฝั‚ะฐะถะธั‚ะธ ะดะฐะฒะฝั–ัˆั– ะฝะพะฒะธะฝะธ", + "How to read the map": "ะฏะบ ั‡ะธั‚ะฐั‚ะธ ะผะฐะฟัƒ", + "Colour: severity ยท icon: type of event": "ะšะพะปั–ั€: ั‚ัะถะบั–ัั‚ัŒ ยท ะทะฝะฐั‡ะพะบ: ั‚ะธะฟ ะฟะพะดั–ั—", + "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.": "ะŸะพะทะฝะฐั‡ะบะธ ัั‚ะพั€ั–ะฝ ะพะฟะธััƒัŽั‚ัŒ ะดะถะตั€ะตะปะพ, ะฐ ะฝะต ะฟั€ะฐะฒะดะธะฒั–ัั‚ัŒ ะฟะพะฒั–ะดะพะผะปะตะฝะฝั.", + "Exit cockpit to fly to a news report": "ะ’ะธะนะดั–ั‚ัŒ ั–ะท ะบะฐะฑั–ะฝะธ, ั‰ะพะฑ ะฟะตั€ะตะปะตั‚ั–ั‚ะธ ะดะพ ะฟะพะฒั–ะดะพะผะปะตะฝะฝั", + "News items in the feed": "ะะพะฒะธะฝ ัƒ ัั‚ั€ั–ั‡ั†ั–", "Pick a shape, then click the map.": "ะ’ะธะฑะตั€ั–ั‚ัŒ ั„ั–ะณัƒั€ัƒ ะน ะบะปะฐั†ะฝั–ั‚ัŒ ะฝะฐ ะบะฐั€ั‚ั–.", "Enter to place the pin, Esc to cancel.": "Enter โ€” ะฟะพัั‚ะฐะฒะธั‚ะธ ะผั–ั‚ะบัƒ, Esc โ€” ัะบะฐััƒะฒะฐั‚ะธ.", "Click where the pin goes.": "ะšะปะฐั†ะฝั–ั‚ัŒ ั‚ะฐะผ, ะดะต ะผะฐั” ะฑัƒั‚ะธ ะผั–ั‚ะบะฐ.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 30a4d8807..0b398051d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,5 +1,13 @@ { "News feed unavailable": "ๆ–ฐ้—ปๆบไธๅฏ็”จ", + "Developing: {name}": "ไบ‹ๆ€ๅ‘ๅฑ•๏ผš{name}", + "{count} events today": { + "other": "ไปŠๆ—ฅ {count} ่ตทไบ‹ไปถ" + }, + "{count} reports today": { + "other": "ไปŠๆ—ฅ {count} ๆกๆŠฅ้“" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "็ฒพ้€‰็ƒญ็‚น็š„ๆœ€่ฟ‘ 24 ๅฐๆ—ถใ€‚ๆฏๆกๆŠฅ้“้ƒฝ้“พๆŽฅๅˆฐๅ…ถๅ‘ๅธƒ่€…๏ผ›ๅœฐ็‚นๅ’Œ็ฑปๅˆซ็”ฑๆ–‡ๆœฌ่‡ชๅŠจ่ฏปๅ–๏ผŒๅฏ่ƒฝๆœ‰่ฏฏใ€‚้˜ต่ฅๆ ‡็ญพๆ่ฟฐ็š„ๆ˜ฏๆฅๆบ๏ผŒ่€Œ้žๆŠฅ้“ๆ˜ฏๅฆๅฑžๅฎžใ€‚", "Disputed: {name}": "ไบ‰่ฎฎๅœฐๅŒบ๏ผš{name}", "Administered by {holder} ยท claimed by {claimant}": "็”ฑ{holder}็ฎก่พ– ยท {claimant}ไธปๅผ ไธปๆƒ", "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ไธปๅผ ไธปๆƒ", @@ -50,6 +58,34 @@ "News feed not configured on this server": "ๆญคๆœๅŠกๅ™จๆœช้…็ฝฎๆ–ฐ้—ปๆบ", "Malformed news hotspots response": "ๆ–ฐ้—ป็ƒญ็‚นๅ“ๅบ”ๆ ผๅผ้”™่ฏฏ", "Malformed news feed response": "ๆ–ฐ้—ปๆบๅ“ๅบ”ๆ ผๅผ้”™่ฏฏ", + "{place} (placed by model)": "{place}๏ผˆ็”ฑๆจกๅž‹ๅฎšไฝ๏ผ‰", + "English headline by the model": "ๆจกๅž‹็”Ÿๆˆ็š„่‹ฑๆ–‡ๆ ‡้ข˜", + "Not placed on the map": "ๆœชๅœจๅœฐๅ›พไธŠๆ ‡ๅ‡บ", + "Close": "ๅ…ณ้—ญ", + "Any severity": "ไปปๆ„ไธฅ้‡็จ‹ๅบฆ", + "{severity} and above": "{severity}ๅŠไปฅไธŠ", + "{severity} only": "ไป…{severity}", + "All categories": "ๆ‰€ๆœ‰็ฑปๅˆซ", + "Reading the newsโ€ฆ": "ๆญฃๅœจ่ฏปๅ–ๆ–ฐ้—ปโ€ฆ", + "News, last 24 hours": "ๆœ€่ฟ‘ 24 ๅฐๆ—ถ็š„ๆ–ฐ้—ป", + "Last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ—ถ", + "Hotspots": "็ƒญ็‚น", + "Severity": "ไธฅ้‡็จ‹ๅบฆ", + "Category": "็ฑปๅˆซ", + "Include minor news": "ๅŒ…ๆ‹ฌๆฌก่ฆๆ–ฐ้—ป", + "Show event cards": "ๆ˜พ็คบไบ‹ไปถๅก็‰‡", + "In view": "่ง†้‡Žๅ†…", + "Nothing reported near here in the last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ—ถ้™„่ฟ‘ๆ— ๆŠฅ้“", + "Latest news": "ๆœ€ๆ–ฐๆ–ฐ้—ป", + "No news matches these filters in the last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ—ถๆฒกๆœ‰็ฌฆๅˆ่ฟ™ไบ›็ญ›้€‰ๆกไปถ็š„ๆ–ฐ้—ป", + "Load older news": "ๅŠ ่ฝฝๆ›ดๆ—ฉ็š„ๆ–ฐ้—ป", + "How to read the map": "ๅฆ‚ไฝ•่งฃ่ฏปๅœฐๅ›พ", + "Colour: severity ยท icon: type of event": "้ขœ่‰ฒ๏ผšไธฅ้‡็จ‹ๅบฆ ยท ๅ›พๆ ‡๏ผšไบ‹ไปถ็ฑปๅž‹", + "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.": "้˜ต่ฅๆ ‡็ญพๆ่ฟฐ็š„ๆ˜ฏๆฅๆบ๏ผŒ่€Œ้žๆŠฅ้“ๆ˜ฏๅฆๅฑžๅฎžใ€‚", + "Exit cockpit to fly to a news report": "้€€ๅ‡บ้ฉพ้ฉถ่ˆฑไปฅ้ฃžๅพ€ๆ–ฐ้—ปๆŠฅ้“", + "News items in the feed": "ๅˆ—่กจไธญ็š„ๆ–ฐ้—ปๆ•ฐ", "Pick a shape, then click the map.": "ๅ…ˆ้€‰ๆ‹ฉๅฝข็Šถ๏ผŒๅ†ๅœจๅœฐๅ›พไธŠ็‚นๅ‡ปใ€‚", "Enter to place the pin, Esc to cancel.": "ๆŒ‰ Enter ๆ”พ็ฝฎๅ›พ้’‰๏ผŒๆŒ‰ Esc ๅ–ๆถˆใ€‚", "Click where the pin goes.": "็‚นๅ‡ปๅ›พ้’‰็š„ๆ”พ็ฝฎไฝ็ฝฎใ€‚", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e29d0fff2..a89b6ba22 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,5 +1,13 @@ { "News feed unavailable": "ๆ–ฐ่žไพ†ๆบ็„กๆณ•ไฝฟ็”จ", + "Developing: {name}": "ไบ‹ๆ…‹็™ผๅฑ•๏ผš{name}", + "{count} events today": { + "other": "ไปŠๆ—ฅ {count} ่ตทไบ‹ไปถ" + }, + "{count} reports today": { + "other": "ไปŠๆ—ฅ {count} ๅ‰‡ๅ ฑๅฐŽ" + }, + "Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.": "็ฒพ้ธ็†ฑ้ปž็š„ๆœ€่ฟ‘ 24 ๅฐๆ™‚ใ€‚ๆฏๅ‰‡ๅ ฑๅฐŽ้ƒฝ้€ฃ็ต่‡ณๅ…ถ็™ผๅธƒ่€…๏ผ›ๅœฐ้ปž่ˆ‡้กžๅˆฅ็”ฑๆ–‡ๅญ—่‡ชๅ‹•ๅˆค่ฎ€๏ผŒๅฏ่ƒฝๆœ‰่ชคใ€‚้™ฃ็‡Ÿๆจ™็ฑคๆ่ฟฐ็š„ๆ˜ฏไพ†ๆบ๏ผŒ่€Œ้žๅ ฑๅฐŽๆ˜ฏๅฆๅฑฌๅฏฆใ€‚", "Disputed: {name}": "็ˆญ่ญฐๅœฐๅ€๏ผš{name}", "Administered by {holder} ยท claimed by {claimant}": "็”ฑ{holder}็ฎก่ฝ„ ยท {claimant}ไธปๅผตไธปๆฌŠ", "Self-administered ยท claimed by {claimant}": "่‡ชๆฒป ยท {claimant}ไธปๅผตไธปๆฌŠ", @@ -50,6 +58,34 @@ "News feed not configured on this server": "ๆญคไผบๆœๅ™จๆœช่จญๅฎšๆ–ฐ่žไพ†ๆบ", "Malformed news hotspots response": "ๆ–ฐ่ž็†ฑ้ปžๅ›žๆ‡‰ๆ ผๅผ้Œฏ่ชค", "Malformed news feed response": "ๆ–ฐ่žไพ†ๆบๅ›žๆ‡‰ๆ ผๅผ้Œฏ่ชค", + "{place} (placed by model)": "{place}๏ผˆ็”ฑๆจกๅž‹ๅฎšไฝ๏ผ‰", + "English headline by the model": "ๆจกๅž‹็”ข็”Ÿ็š„่‹ฑๆ–‡ๆจ™้กŒ", + "Not placed on the map": "ๆœชๅœจๅœฐๅœ–ไธŠๆจ™ๅ‡บ", + "Close": "้—œ้–‰", + "Any severity": "ไปปๆ„ๅšด้‡็จ‹ๅบฆ", + "{severity} and above": "{severity}ๅŠไปฅไธŠ", + "{severity} only": "ๅƒ…{severity}", + "All categories": "ๆ‰€ๆœ‰้กžๅˆฅ", + "Reading the newsโ€ฆ": "ๆญฃๅœจ่ฎ€ๅ–ๆ–ฐ่žโ€ฆ", + "News, last 24 hours": "ๆœ€่ฟ‘ 24 ๅฐๆ™‚็š„ๆ–ฐ่ž", + "Last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ™‚", + "Hotspots": "็†ฑ้ปž", + "Severity": "ๅšด้‡็จ‹ๅบฆ", + "Category": "้กžๅˆฅ", + "Include minor news": "ๅŒ…ๆ‹ฌๆฌก่ฆๆ–ฐ่ž", + "Show event cards": "้กฏ็คบไบ‹ไปถๅก็‰‡", + "In view": "่ฆ–้‡Žๅ…ง", + "Nothing reported near here in the last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ™‚้™„่ฟ‘็„กๅ ฑๅฐŽ", + "Latest news": "ๆœ€ๆ–ฐๆ–ฐ่ž", + "No news matches these filters in the last 24 h": "ๆœ€่ฟ‘ 24 ๅฐๆ™‚ๆฒ’ๆœ‰็ฌฆๅˆ้€™ไบ›็ฏฉ้ธๆขไปถ็š„ๆ–ฐ่ž", + "Load older news": "่ผ‰ๅ…ฅๆ›ดๆ—ฉ็š„ๆ–ฐ่ž", + "How to read the map": "ๅฆ‚ไฝ•่งฃ่ฎ€ๅœฐๅœ–", + "Colour: severity ยท icon: type of event": "้ก่‰ฒ๏ผšๅšด้‡็จ‹ๅบฆ ยท ๅœ–็คบ๏ผšไบ‹ไปถ้กžๅž‹", + "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.": "้™ฃ็‡Ÿๆจ™็ฑคๆ่ฟฐ็š„ๆ˜ฏไพ†ๆบ๏ผŒ่€Œ้žๅ ฑๅฐŽๆ˜ฏๅฆๅฑฌๅฏฆใ€‚", + "Exit cockpit to fly to a news report": "้€€ๅ‡บ้ง•้ง›่‰™ไปฅ้ฃ›ๅพ€ๆ–ฐ่žๅ ฑๅฐŽ", + "News items in the feed": "ๅˆ—่กจไธญ็š„ๆ–ฐ่žๆ•ธ", "Pick a shape, then click the map.": "ๅ…ˆ้ธๆ“‡ๅฝข็‹€๏ผŒๅ†ๅœจๅœฐๅœ–ไธŠ้ปžๆ“Šใ€‚", "Enter to place the pin, Esc to cancel.": "ๆŒ‰ Enter ๆ”พ็ฝฎๅœ–้‡˜๏ผŒๆŒ‰ Esc ๅ–ๆถˆใ€‚", "Click where the pin goes.": "้ปžๆ“Šๅœ–้‡˜็š„ๆ”พ็ฝฎไฝ็ฝฎใ€‚", diff --git a/src/layers/news/index.js b/src/layers/news/index.js new file mode 100644 index 000000000..2f8c08f90 --- /dev/null +++ b/src/layers/news/index.js @@ -0,0 +1,1240 @@ +import * as Cesium from 'cesium'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; +import { + isOwnedByOtherLayer, + registerPickOwner, + resolvePickId, + unregisterPickOwner, +} from '../../data/pickRegistry.js'; +import { + clearSelectedEntityContextForLayer, + registerEntityContext, + removeEntityContextsForLayer, + selectEntityContext, +} from '../../data/contextStore.js'; +import { msg, t, tn } from '../../i18n/index.js'; +import { createHorizonCull, viewCentre } from '../streetLevel/view.js'; +import { + NEWS_CARD_MIN_SOURCES, + NEWS_CATEGORIES, + NEWS_DETAIL_MAX_HEIGHT_M, + NEWS_FEED_PAGE, + NEWS_FOCUS_HEIGHT_M, + NEWS_IN_VIEW_MAX_HEIGHT_M, + NEWS_LAYER_ID, + NEWS_MAP_LIMIT, + NEWS_OVERLAY_SOURCE_ID, + NEWS_PICK_PREFIX, + NEWS_RELEVANT, + NEWS_SETTLE_MS, + NEWS_SEVERITY, + ageWords, + attestation, + beaconPixelSize, + cardEvents, + cardView, + categoryLabel, + disputedLabel, + feedCardView, + feedQueryFor, + hotspotLabel, + hotspotTrend, + isPressing, + markerStyle, + mediaLabels, + mergeFeedPage, + normalizeFilters, + passesFilters, + reportMarkerPosition, + reportsInView, + severityStyle, + shortTitle, + sideLabel, + sidesLine, + sourceLinkLabel, + viewCell, + visibleOnMap, + withinNewsWindow, +} from './model.js'; +import { SPRITE_SCALE, markerSprite } from './sprites.js'; +import { CARD_H, CARD_W, eventCard } from './cards.js'; +export * from './model.js'; +export { createNewsSource } from './source.js'; + +const FEED_UNAVAILABLE = msg('News feed unavailable'); +const REPORT_PREFIX = `${NEWS_PICK_PREFIX}report:`; +const HOTSPOT_PREFIX = `${NEWS_PICK_PREFIX}hotspot:`; +const DEVELOPING_PREFIX = `${NEWS_PICK_PREFIX}developing:`; +const DEVELOPING_COLOR = '#ffb020'; +// Disputed areas: dotted, in their own colour, apart from ordinary borders. +const DISPUTED_COLOR = '#f472b6'; +const HOTSPOT_COLOR = '#00d4ff'; +// A filtered feed page that shows almost nothing reads the next page by +// itself, a few times, so a strict severity floor still fills the list. +const AUTO_PAGES = 3; +const PULSE_MS = 1_600; +const emptyFeed = () => ({ + items: [], + nextBefore: null, + loading: false, + error: null, +}); +const emptyInView = () => ({ + active: false, + cell: null, + centre: null, + reports: [], + loading: false, + error: null, +}); + +/** + * News from curated hotspots, real time only (the last 24 hours). Zoomed out: + * one beacon per hotspot with its daily count and trend, and a breathing + * beacon per developing situation. Close up or with a hotspot chosen: + * outlines (disputed areas dotted) and the geocoded reports as markers, the + * big events as picture cards. The News panel renders the feed this layer + * publishes through getRowControls().feed. + */ +export function createNewsLayer({ + source, + overlayHost, + cesium = Cesium, + matchMedia = globalThis.matchMedia?.bind(globalThis), + now = () => Date.now(), + hitTestOverlay = hitTestWorldOverlay, + openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'), + getViewCentre = viewCentre, +} = {}) { + if ( + typeof source?.getHotspots !== 'function' || + typeof source?.getFeed !== 'function' + ) + throw new TypeError('News requires a news source'); + if (!overlayHost) throw new TypeError('News requires an overlay host'); + + let viewer = null, + dataSource = null, + outlineSource = null, + clickHandler = null, + listener = null, + runNavigation = null, + request = null, + feedRequest = null, + inViewRequest = null, + settleTimer = null, + pulseTimer = null, + removeCameraListener = null; + let enabled = false, + destroyed = false, + loading = false, + unavailable = false, + error = null, + lastUpdate = null, + lastRunAt = null, + autoPages = 0; + let hotspots = []; + let mapReports = []; + let feed = emptyFeed(); + let inView = emptyInView(); + let filters = normalizeFilters(); + let selectedId = null, + selectedPin = 0; + let shownDetail = null; + let cullTargets = []; + const contextEntities = new Map(); + const notify = () => listener?.(); + const requestRender = () => viewer?.scene?.requestRender?.(); + const reducedMotion = () => + Boolean(matchMedia?.('(prefers-reduced-motion: reduce)')?.matches); + + const findReport = (id) => + mapReports.find((r) => r.id === id) || + feed.items.find((r) => r.id === id) || + inView.reports.find((r) => r.id === id) || + null; + const selected = () => (selectedId ? findReport(selectedId) : null); + const onMap = (report) => visibleOnMap(report, filters, now()); + + function markerCartesian(report, pin = 0) { + const { lat, lon } = reportMarkerPosition(report, pin); + return cesium.Cartesian3.fromDegrees(lon, lat); + } + + const cameraHeight = () => viewer?.camera?.positionCartographic?.height; + + // Global view: one beacon per hotspot. Close up: outlines and individual + // reports. A chosen hotspot keeps the detail out to three times the height + // (its whole area fits on screen). + function detailMode() { + const height = cameraHeight(); + if (!Number.isFinite(height)) return filters.hotspot !== 'all'; + const limit = + filters.hotspot === 'all' + ? NEWS_DETAIL_MAX_HEIGHT_M + : NEWS_DETAIL_MAX_HEIGHT_M * 3; + return height < limit; + } + + // The catch-all ("Elsewhere") has no area to stand for: its reports are + // drawn as markers at every zoom instead of a beacon or an outline. + const catchAllIds = () => + new Set(hotspots.filter((h) => h.catchAll).map((h) => h.id)); + + // Markers draw over terrain (depth test off) so a pin is never swallowed by + // the ground it sits on; the shared horizon cull hides the far side. + const horizon = createHorizonCull({ + getViewer: () => viewer, + items: () => cullTargets, + onChange: () => { + requestRender(); + updatePulse(); + }, + }); + + // A developing situation's beacon breathes, unless motion is reduced. The + // scene renders on demand, so a slow timer asks for frames only while such + // a beacon is in view. + const pulseSize = (size) => + size * + (1 + 0.35 * (0.5 + 0.5 * Math.sin((Date.now() / PULSE_MS) * 2 * Math.PI))); + function updatePulse() { + const pulsing = + enabled && + !reducedMotion() && + cullTargets.some((target) => target.developing && target.show); + if (pulsing && !pulseTimer) pulseTimer = setInterval(requestRender, 250); + else if (!pulsing && pulseTimer) { + clearInterval(pulseTimer); + pulseTimer = null; + } + } + + const ambientLabel = (extra) => ({ + variant: 'label', + collisionGroup: 'ambient-label', + paintLane: 'ambient-label', + interactive: false, + horizonCull: true, + terrainOcclusion: false, + placement: 'above', + ...extra, + }); + + // Several separate events in one country outside every hotspot. + function renderDeveloping(hotspot, entities, labels) { + const still = reducedMotion(); + for (const item of hotspot.developing || []) { + const position = cesium.Cartesian3.fromDegrees( + item.focus.lon, + item.focus.lat, + ); + const size = beaconPixelSize(item.reports); + entities.add({ + id: `${DEVELOPING_PREFIX}${hotspot.id}:${item.key}`, + position, + point: { + pixelSize: still + ? size + : new cesium.CallbackProperty(() => pulseSize(size), false), + color: + cesium.Color.fromCssColorString(DEVELOPING_COLOR).withAlpha(0.35), + outlineColor: cesium.Color.fromCssColorString(DEVELOPING_COLOR), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + labels.push( + ambientLabel({ + id: `developing:${item.key}`, + position, + title: t('Developing: {name}', { name: item.name }), + details: [ + tn(item.events, '{count} event today', '{count} events today'), + ], + accent: DEVELOPING_COLOR, + priority: 9_000 + item.reports, + gapPx: size / 2 + 4, + }), + ); + } + } + + function renderBeacons(entities, labels) { + for (const hotspot of hotspots) { + if (hotspot.catchAll) { + renderDeveloping(hotspot, entities, labels); + continue; + } + const { lat, lon } = hotspot.focus; + const position = cesium.Cartesian3.fromDegrees(lon, lat); + entities.add({ + id: `${HOTSPOT_PREFIX}${hotspot.id}`, + position, + point: { + pixelSize: beaconPixelSize(hotspot.last24h), + color: cesium.Color.fromCssColorString(HOTSPOT_COLOR).withAlpha(0.35), + outlineColor: cesium.Color.fromCssColorString(HOTSPOT_COLOR), + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + const trend = hotspotTrend(hotspot.last24h, hotspot.prev24h); + labels.push( + ambientLabel({ + id: `hotspot:${hotspot.id}`, + position, + title: hotspotLabel(hotspot), + details: [ + `${tn(hotspot.last24h, '{count} report today', '{count} reports today')}${trend ? ` ${trend}` : ''}`, + ], + accent: HOTSPOT_COLOR, + priority: 10_000 + hotspot.last24h, + gapPx: beaconPixelSize(hotspot.last24h) / 2 + 4, + }), + ); + } + } + + function renderHotspotOutlines(entities, labels) { + for (const hotspot of hotspots) { + if (hotspot.catchAll) continue; + const [w, s, e, n] = hotspot.bbox; + const active = filters.hotspot === hotspot.id; + // The bounding box only until the real outline has loaded. + if (!outlineParts.has(hotspot.id)) + entities.add({ + id: `${HOTSPOT_PREFIX}${hotspot.id}`, + polyline: { + positions: cesium.Cartesian3.fromDegreesArray([ + w, + s, + e, + s, + e, + n, + w, + n, + w, + s, + ]), + width: active ? 3 : 2, + clampToGround: true, + material: new cesium.PolylineDashMaterialProperty({ + color: cesium.Color.fromCssColorString(HOTSPOT_COLOR).withAlpha( + active ? 0.95 : 0.55, + ), + dashLength: 18, + }), + }, + }); + labels.push( + ambientLabel({ + id: `hotspot:${hotspot.id}`, + position: outlineParts.has(hotspot.id) + ? cesium.Cartesian3.fromDegrees( + hotspot.focus.lon, + hotspot.focus.lat, + ) + : cesium.Cartesian3.fromDegrees(w, n), + title: hotspotLabel(hotspot), + details: [ + tn( + hotspot.last24h, + '{count} report today', + '{count} reports today', + ), + ], + accent: HOTSPOT_COLOR, + priority: 10_000 + hotspot.last24h, + }), + ); + // Each disputed area is named, with who holds it and who claims it. + for (const area of outlineDisputed.get(hotspot.id) || []) { + const ring = area.rings[0]; + const lon = ring.reduce((sum, p) => sum + p[0], 0) / ring.length; + const lat = ring.reduce((sum, p) => sum + p[1], 0) / ring.length; + const label = disputedLabel(area); + labels.push( + ambientLabel({ + id: `disputed:${hotspot.id}:${area.name}`, + position: cesium.Cartesian3.fromDegrees(lon, lat), + title: label.title, + details: [label.detail], + accent: DISPUTED_COLOR, + priority: 8_000, + }), + ); + } + } + } + + function renderMarkers(entities, drawn) { + for (const report of drawn) { + const isSelected = report.id === selectedId; + if (!report.pins.length || (!isSelected && !onMap(report))) continue; + // One marker per pin; the pin index rides on the pick id. Fill is + // severity, the icon the category, the rim attestation; a marker grows + // with the sources reporting its event and fades with age. A fresh, + // widely reported or major event also gets a static halo. + const look = markerStyle(report, now()); + const color = cesium.Color.fromCssColorString( + severityStyle(report).color, + ); + const size = isSelected ? look.size + 4 : look.size; + const sprite = markerSprite({ ...look, selected: isSelected }); + const pressing = isPressing(report, now()); + for (const [pin] of report.pins.entries()) { + const position = markerCartesian(report, pin); + if (pressing) + entities.add({ + id: `${REPORT_PREFIX}${report.id}~${pin}~halo`, + position, + point: { + pixelSize: look.size * 2.4, + color: color.withAlpha(0.22), + outlineColor: color.withAlpha(0.6), + outlineWidth: 1, + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + entities.add({ + id: `${REPORT_PREFIX}${report.id}~${pin}`, + position, + ...(sprite + ? { + billboard: { + image: sprite, + width: Math.round(size * SPRITE_SCALE), + height: Math.round(size * SPRITE_SCALE), + color: cesium.Color.WHITE.withAlpha( + isSelected ? 1 : look.alpha, + ), + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + } + : { + point: { + pixelSize: size, + color, + outlineColor: isSelected + ? cesium.Color.WHITE + : cesium.Color.BLACK.withAlpha(0.7), + outlineWidth: isSelected ? 3 : 1.5, + heightReference: cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }), + }); + } + } + } + + // The big events stand as picture cards over their marker, unless the + // reader has turned them off; never two on top of each other. The clicked + // event shows its picture card either way, under its marker, with its text + // card above. + function pictureCards(drawn, current, labels) { + const cardable = filters.cards + ? cardEvents( + drawn.filter((r) => r.id !== selectedId && onMap(r)), + now(), + ) + : []; + const pictured = + current?.pins.length && + current.event?.size >= NEWS_CARD_MIN_SOURCES && + current.pins[selectedPin] + ? [current] + : []; + for (const [rank, report] of [...pictured, ...cardable].entries()) { + const isSelected = report.id === selectedId; + const look = markerStyle(report, now()); + const view = cardView(report, now()); + const image = eventCard( + report.event?.id || report.id, + view, + look, + requestRender, + ); + if (!image) continue; + const color = severityStyle(report).color; + labels.push({ + id: `event-card:${report.id}`, + position: markerCartesian(report, isSelected ? selectedPin : 0), + variant: 'thumbnail', + paintLane: 'thumbnail', + title: '', + details: [], + image, + accent: color, + priority: isSelected ? Number.MAX_SAFE_INTEGER - 1 : 500_000 - rank, + collisionGroup: isSelected ? 'news-selected-picture' : 'ambient-card', + interactive: true, + accessibilityLabel: `${view.title}. ${view.meta}`, + activate: () => layer.setParams({ reportId: report.id, pin: 0 }), + horizonCull: true, + terrainOcclusion: false, + placement: isSelected ? 'below' : 'above', + verticalOnly: true, + gapPx: look.size / 2 + 12, + leaderOffsetPx: look.size / 2 + 2, + viewportPadding: 24, + // The collision box: rectangle overlap alone lets the cards of nearby + // anchors stack. + minAnchorSeparationPx: isSelected ? 0 : CARD_W * 0.85, + thumbnailWidth: CARD_W, + thumbnailHeight: CARD_H, + thumbnailPadX: 0, + thumbnailPadTop: 0, + thumbnailPadBottom: 0, + thumbnailTitleGap: 0, + thumbnailTitleHeight: 0, + thumbnailTitleChars: 0, + thumbnailBackground: 'rgba(0, 0, 0, 0)', + thumbnailLeaderColor: color, + thumbnailRuleHeight: 0, + thumbnailRadius: 4, + }); + } + } + + // The selected report's card: what, where, how attested, and the link to + // the original article ("Source: host โ†—"), which a click on it opens. + function selectedCard(report, labels) { + if (!report?.pins.length) return; + const pin = report.pins[selectedPin] ? selectedPin : 0; + labels.push({ + id: `selected-report:${report.id}`, + position: markerCartesian(report, pin), + variant: 'card', + title: shortTitle(report.titleEn || report.title), + details: [ + [ + report.publisher, + sideLabel(report.side), + ageWords(report.publishedAt, now()), + ] + .filter(Boolean) + .join(' ยท '), + [ + categoryLabel(report.category), + t(severityStyle(report).label), + report.pins[pin].place, + ] + .filter(Boolean) + .join(' ยท '), + sidesLine(report), + attestation(report)?.label || '', + mediaLabels(report).join(' ยท '), + sourceLinkLabel(report.url), + ].filter(Boolean), + accent: severityStyle(report).color, + selected: true, + stateless: true, + priority: Number.MAX_SAFE_INTEGER, + interactive: true, + accessibilityLabel: sourceLinkLabel(report.url), + activate: () => { + openUrl(report.url); + return true; + }, + horizonCull: true, + terrainOcclusion: false, + placement: 'above', + gapPx: 12, + }); + } + + function render() { + if (!dataSource) return; + const entities = dataSource.entities; + // Removed before events are suspended: a marker removed and re-added + // under the same id in one suspended batch reaches Cesium's visualizers + // as "changed", and they keep drawing the removed entity (i.e. nothing). + entities.removeAll(); + entities.suspendEvents(); + const labels = []; + const detail = detailMode(); + shownDetail = detail; + if (detail) renderHotspotOutlines(entities, labels); + else renderBeacons(entities, labels); + // A report picked in the feed or In view still gets its marker. + const current = selected(); + const elsewhere = catchAllIds(); + const shown = detail + ? mapReports + : mapReports.filter((r) => elsewhere.has(r.hotspotId)); + const drawn = + current && !shown.some((r) => r.id === current.id) + ? [...shown, current] + : shown; + renderMarkers(entities, drawn); + entities.resumeEvents(); + showOutlines(detail); + const at = cesium.JulianDate.now(); + cullTargets = entities.values + .filter((entity) => (entity.point || entity.billboard) && entity.position) + .map((entity) => ({ + position: entity.position.getValue(at), + developing: String(entity.id).startsWith(DEVELOPING_PREFIX), + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + })) + .filter((target) => target.position); + horizon.update(); + updatePulse(); + pictureCards(drawn, current, labels); + selectedCard(current, labels); + if (enabled) + overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false }); + requestRender(); + } + + function registerContext(report) { + const place = report?.pins[selectedPin] || report?.geo; + if (!place) return null; + let entity = contextEntities.get(report.id); + if (!entity) { + entity = { show: true }; + contextEntities.set(report.id, entity); + } + const { lat, lon } = reportMarkerPosition( + report, + report.pins[selectedPin] ? selectedPin : 0, + ); + entity.__localBaseCartesian = cesium.Cartesian3.fromDegrees(lon, lat); + registerEntityContext(entity, { + id: `news-report:${report.id}`, + layerId: NEWS_LAYER_ID, + layerName: 'News', + source: report.publisher, + dataSource, + label: report.title, // i18n-ignore: publisher headline (data) + latitude: place.lat, + longitude: place.lon, + properties: { + publisher: report.publisher, + side: report.side, + category: report.category, + place: place.place, + publishedAt: report.publishedAt, + url: report.url, + }, + }); + return entity; + } + + function select(id, pin = 0) { + selectedId = id; + selectedPin = pin; + const report = selected(); + if (report) { + const entity = registerContext(report); + if (entity) selectEntityContext(entity); + } else clearSelectedEntityContextForLayer(NEWS_LAYER_ID); + render(); + notify(); + } + + function flyTo(navigate) { + if (!runNavigation || !viewer) return; + runNavigation(() => (enabled && !destroyed ? navigate() : undefined)); + } + + // A card's click: straight down over the event from 150 km. + function focusReport(report) { + if (!report?.pins.length) return; + const { lat, lon } = report.pins[selectedPin] || report.pins[0]; + flyTo(() => + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + lon, + lat, + NEWS_FOCUS_HEIGHT_M, + ), + duration: reducedMotion() ? 0 : 1.4, + }), + ); + } + + function focusRectangle([w, s, e, n]) { + flyTo(() => + viewer.camera.flyTo({ + destination: cesium.Rectangle.fromDegrees(w, s, e, n), + duration: reducedMotion() ? 0 : 1.6, + }), + ); + } + + // Hotspot extents (and developing countries) are drawn in their own data + // source, built once per outline and only shown or hidden afterwards: + // ground polygons over 3D tiles are slow to build, so they are never + // rebuilt on a re-render. A hotspot's fill clears when it is the chosen + // one so its border stays readable over the reports inside. + const outlineParts = new Map(); + const outlineRequests = new Set(); + const outlineDisputed = new Map(); + function addOutline( + key, + { polygons, borders, disputed = [] }, + { color, fillAlpha, pickId }, + ) { + if (!outlineSource || outlineParts.has(key)) return; + const css = cesium.Color.fromCssColorString(color); + const parts = { fills: [], borders: [] }; + const ring = (points) => cesium.Cartesian3.fromDegreesArray(points.flat()); + const closed = (points) => ring([...points, points[0]]); + const line = (id, positions, width, material) => + parts.borders.push( + outlineSource.entities.add({ + id, + show: false, + polyline: { positions, width, clampToGround: true, material }, + }), + ); + for (const [index, polygon] of polygons.entries()) { + if (fillAlpha > 0) + parts.fills.push( + outlineSource.entities.add({ + id: `${pickId}~fill${index}`, + show: false, + polygon: { + hierarchy: new cesium.PolygonHierarchy( + ring(polygon.outer), + polygon.holes.map( + (hole) => new cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: css.withAlpha(fillAlpha), + classificationType: cesium.ClassificationType.BOTH, + }, + }), + ); + for (const [k, points] of [polygon.outer, ...polygon.holes].entries()) + line( + `${pickId}~line${index}.${k}`, + closed(points), + 2, + css.withAlpha(0.85), + ); + } + // National borders inside a multi-country hotspot: thin and dashed. + for (const [index, points] of borders.entries()) + line( + `${pickId}~border${index}`, + closed(points), + 1.5, + new cesium.PolylineDashMaterialProperty({ + color: css.withAlpha(0.6), + dashLength: 12, + }), + ); + const dotted = new cesium.PolylineDashMaterialProperty({ + color: cesium.Color.fromCssColorString(DISPUTED_COLOR).withAlpha(0.9), + dashLength: 6, + }); + for (const [index, area] of disputed.entries()) + for (const [k, points] of area.rings.entries()) + line(`${pickId}~disputed${index}.${k}`, closed(points), 2, dotted); + outlineDisputed.set(key, disputed); + outlineParts.set(key, parts); + } + function loadOutline(key, fetchOutline, style) { + if (outlineParts.has(key) || outlineRequests.has(key) || !fetchOutline) + return; + outlineRequests.add(key); + fetchOutline() + .then((outline) => { + if (destroyed) return; + addOutline(key, outline, style); + if (enabled) render(); + }) + // An outline is an extra: the bounding box stands in for it. + .catch(() => {}) + .finally(() => outlineRequests.delete(key)); + } + function loadOutlines() { + for (const hotspot of hotspots) { + if (!hotspot.catchAll) + loadOutline( + hotspot.id, + source.getOutline && (() => source.getOutline(hotspot.id)), + { + color: HOTSPOT_COLOR, + fillAlpha: 0.08, + pickId: `${HOTSPOT_PREFIX}${hotspot.id}`, + }, + ); + for (const item of hotspot.developing || []) + loadOutline( + `developing:${item.key}`, + source.getCountryOutline && + (() => source.getCountryOutline(item.key)), + { + color: DEVELOPING_COLOR, + fillAlpha: 0, + pickId: `${DEVELOPING_PREFIX}${hotspot.id}:${item.key}`, + }, + ); + } + } + function showOutlines(detail) { + const developing = new Set( + hotspots.flatMap((h) => (h.developing || []).map((d) => d.key)), + ); + for (const [key, parts] of outlineParts) { + const country = key.startsWith('developing:') ? key.slice(11) : null; + const visible = enabled && (country ? developing.has(country) : detail); + const fill = visible && !country && filters.hotspot !== key; + for (const entity of parts.fills) + if (entity.show !== fill) entity.show = fill; + for (const entity of parts.borders) + if (entity.show !== visible) entity.show = visible; + } + } + + const shownCount = () => + feed.items.filter((r) => passesFilters(r, filters, now())).length; + + async function loadFeed(mode) { + if (!enabled || unavailable) return; + if (mode === 'append' && (!feed.nextBefore || feed.loading)) return; + if (mode === 'replace') autoPages = 0; + feedRequest?.abort(); + const controller = new AbortController(); + feedRequest = controller; + feed = { ...feed, loading: true, error: null }; + notify(); + try { + const page = await source.getFeed( + feedQueryFor( + filters, + mode === 'append' ? { before: feed.nextBefore } : {}, + ), + { signal: controller.signal }, + ); + if (controller.signal.aborted || feedRequest !== controller || !enabled) + return; + feed = { + ...mergeFeedPage(feed, page, mode), + loading: false, + error: null, + }; + } catch (cause) { + if (controller.signal.aborted || feedRequest !== controller) return; + feed = { + ...feed, + loading: false, + error: cause?.message || FEED_UNAVAILABLE, + }; + } finally { + if (feedRequest === controller) feedRequest = null; + notify(); + } + if ( + enabled && + !feed.error && + feed.nextBefore && + shownCount() < NEWS_FEED_PAGE / 3 && + autoPages < AUTO_PAGES + ) { + autoPages++; + void loadFeed('append'); + } + } + + // "In view": below 3,000 km, the reports within 500 km of the view centre, + // read again once the camera has settled for 0.8 s in another half-degree + // cell. The feed has no area query, so the last day's geocoded reports are + // read and kept by distance here. + function scheduleInView() { + clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = null; + void refreshInView(); + }, NEWS_SETTLE_MS); + } + async function refreshInView({ force = false } = {}) { + if (!enabled || unavailable) return; + const height = cameraHeight(); + if (!(height < NEWS_IN_VIEW_MAX_HEIGHT_M)) { + if (inView.active) { + inViewRequest?.abort(); + inViewRequest = null; + inView = emptyInView(); + notify(); + } + return; + } + const centre = getViewCentre(viewer); + if (!centre) return; + const cell = viewCell(centre); + if (!force && inView.active && cell === inView.cell) return; + inViewRequest?.abort(); + const controller = new AbortController(); + inViewRequest = controller; + inView = { ...inView, active: true, cell, centre, loading: true }; + notify(); + try { + const page = await source.getFeed( + { + hotspot: 'all', + geo: true, + today: true, + events: true, + relevance: filters.includeMinor ? '' : NEWS_RELEVANT, + limit: NEWS_MAP_LIMIT, + }, + { signal: controller.signal }, + ); + if (controller.signal.aborted || inViewRequest !== controller) return; + inView = { + ...inView, + reports: page.reports, + loading: false, + error: null, + }; + } catch (cause) { + if (controller.signal.aborted || inViewRequest !== controller) return; + inView = { + ...inView, + loading: false, + error: cause?.message || FEED_UNAVAILABLE, + }; + } finally { + if (inViewRequest === controller) inViewRequest = null; + notify(); + } + } + + function installSelection() { + if (clickHandler || !viewer?.scene?.canvas) return; + clickHandler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + clickHandler.setInputAction((click) => { + if (!enabled || destroyed || !isPointerFree() || !click?.position) return; + const card = hitTestOverlay(click.position.x, click.position.y, { + sourceId: NEWS_OVERLAY_SOURCE_ID, + }); + if (card?.entry?.activate) { + card.entry.activate(); + return; + } + const picked = viewer.scene.pick(click.position); + const id = picked ? resolvePickId(picked) : null; + if (typeof id === 'string' && id.startsWith(REPORT_PREFIX)) { + const [reportId, pin] = id.slice(REPORT_PREFIX.length).split('~'); + layer.setParams({ reportId, pin: Number(pin) || 0 }); + return; + } + if (typeof id === 'string' && id.startsWith(DEVELOPING_PREFIX)) { + const [hotspotId, key] = id + .slice(DEVELOPING_PREFIX.length) + .split('~')[0] + .split(':'); + const item = hotspots + .find((h) => h.id === hotspotId) + ?.developing?.find((d) => d.key === key); + layer.setParams({ filters: { hotspot: hotspotId } }); + if (item) { + const { lat, lon } = item.focus; + focusRectangle([ + lon - 5, + Math.max(-85, lat - 3.5), + lon + 5, + Math.min(85, lat + 3.5), + ]); + } + return; + } + if (typeof id === 'string' && id.startsWith(HOTSPOT_PREFIX)) { + const hotspotId = id.slice(HOTSPOT_PREFIX.length).split('~')[0]; + // Clicking the hotspot already in focus steps out of a report. + if (filters.hotspot === hotspotId) { + if (selectedId) layer.setParams({ clear: true }); + return; + } + layer.setParams({ filters: { hotspot: hotspotId } }); + return; + } + if (id && isOwnedByOtherLayer(NEWS_LAYER_ID, id)) return; + if (selectedId && !picked?.id) layer.setParams({ clear: true }); + }, cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeSelection() { + if (clickHandler && !clickHandler.isDestroyed?.()) clickHandler.destroy(); + clickHandler = null; + } + + function feedView() { + const at = now(); + const report = selected(); + const total = hotspots.reduce((sum, h) => sum + h.last24h, 0); + return { + filters, + hotspots: [ + { id: 'all', label: t('All'), count: total }, + ...hotspots.map((hotspot) => ({ + id: hotspot.id, + label: hotspotLabel(hotspot), + count: hotspot.last24h, + })), + ].map((chip) => ({ ...chip, active: filters.hotspot === chip.id })), + categories: Object.keys(NEWS_CATEGORIES).map((id) => ({ + value: id, + label: categoryLabel(id), + })), + severities: NEWS_SEVERITY.map((level, index) => ({ + value: index, + label: t(level.label), + color: level.color, + })), + items: feed.items + .filter((r) => passesFilters(r, filters, at)) + .map((r) => feedCardView(r, at)), + inView: { + active: inView.active, + loading: inView.loading, + error: inView.error ? t(FEED_UNAVAILABLE) : null, + items: reportsInView(inView.reports, inView.centre, filters, at).map( + (r) => feedCardView(r, at), + ), + }, + selected: + report && withinNewsWindow(report, at) + ? feedCardView(report, at) + : null, + hasMore: Boolean(feed.nextBefore), + loading: feed.loading || loading, + // Read failures are English data; the panel shows them translated. + error: error || feed.error ? t(error || feed.error) : null, + lastRunAt, + }; + } + + const layer = { + id: NEWS_LAYER_ID, + name: 'News', + icon: 'โ—†', + source: 'GEV news', + updateInterval: 120_000, + + init(nextViewer) { + viewer = nextViewer; + dataSource = new cesium.CustomDataSource(NEWS_LAYER_ID); + outlineSource = new cesium.CustomDataSource(`${NEWS_LAYER_ID}-outlines`); + dataSource.show = false; + outlineSource.show = false; + viewer.dataSources?.add(outlineSource); + viewer.dataSources?.add(dataSource); + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + }, + attachShellServices(services) { + runNavigation = + typeof services?.runNavigation === 'function' + ? services.runNavigation + : null; + }, + enable() { + if (destroyed || enabled) return; + enabled = true; + if (dataSource) dataSource.show = true; + if (outlineSource) outlineSource.show = true; + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, true); + registerPickOwner( + NEWS_LAYER_ID, + (id) => enabled && String(id).startsWith(NEWS_PICK_PREFIX), + ); + installSelection(); + // Re-render only when a camera move crosses the detail threshold. + removeCameraListener = + viewer?.camera?.moveEnd?.addEventListener?.(() => { + if (!enabled) return; + if (detailMode() !== shownDetail) render(); + scheduleInView(); + }) || null; + }, + disable() { + enabled = false; + unavailable = false; + error = null; + request?.abort(); + feedRequest?.abort(); + inViewRequest?.abort(); + request = feedRequest = inViewRequest = null; + clearTimeout(settleTimer); + settleTimer = null; + loading = false; + unregisterPickOwner(NEWS_LAYER_ID); + removeSelection(); + removeCameraListener?.(); + removeCameraListener = null; + horizon.stop(); + cullTargets = []; + clearInterval(pulseTimer); + pulseTimer = null; + if (outlineSource) outlineSource.show = false; + selectedId = null; + clearSelectedEntityContextForLayer(NEWS_LAYER_ID); + removeEntityContextsForLayer(NEWS_LAYER_ID); + contextEntities.clear(); + feed = emptyFeed(); + inView = emptyInView(); + mapReports = []; + if (dataSource) { + dataSource.entities.removeAll(); + dataSource.show = false; + } + overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID); + overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + requestRender(); + notify(); + }, + async update(_viewer, { signal } = {}) { + if (!enabled || destroyed) return false; + // A server without the news routes says so once; no background retries. + if (unavailable) return true; + request?.abort(); + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); + request = controller; + loading = true; + notify(); + try { + const [index, todays] = await Promise.all([ + source.getHotspots({ signal: controller.signal }), + source.getFeed( + { + hotspot: 'all', + geo: true, + today: true, + events: true, + relevance: NEWS_RELEVANT, + limit: NEWS_MAP_LIMIT, + }, + { signal: controller.signal }, + ), + ]); + if (!enabled || controller.signal.aborted || request !== controller) + return false; + hotspots = index.hotspots; + loadOutlines(); + lastRunAt = index.lastRunAt; + mapReports = todays.reports.filter((r) => withinNewsWindow(r, now())); + filters = normalizeFilters({}, hotspots, filters); + error = null; + lastUpdate = now(); + render(); + void loadFeed(feed.items.length ? 'head' : 'replace'); + void refreshInView({ force: inView.active }); + return true; + } catch (cause) { + if (controller.signal.aborted || request !== controller) return false; + unavailable = cause?.unavailable === true; + error = cause?.message || FEED_UNAVAILABLE; + return true; + } finally { + signal?.removeEventListener('abort', abort); + if (request === controller) { + request = null; + loading = false; + notify(); + } + } + }, + setParams(params = {}) { + if (!enabled || destroyed) return; + if (params.clear === true) { + select(null); + return; + } + if (params.filters && typeof params.filters === 'object') { + const next = normalizeFilters(params.filters, hotspots, filters); + // The severity floor and the cards change what is shown, not what is read. + const readKey = ({ hotspot, category, includeMinor }) => + `${hotspot}|${category}|${includeMinor}`; + const reread = readKey(next) !== readKey(filters); + const minorChanged = next.includeMinor !== filters.includeMinor; + filters = next; + if ( + Object.hasOwn(params.filters, 'hotspot') && + next.hotspot !== 'all' + ) { + const hotspot = hotspots.find((h) => h.id === next.hotspot); + // The catch-all spans the world: choosing it filters, it does not fly. + if (hotspot && !hotspot.catchAll) focusRectangle(hotspot.bbox); + } + if (reread) feed = emptyFeed(); + render(); + if (reread) void loadFeed('replace'); + if (minorChanged) void refreshInView({ force: true }); + notify(); + } + if (params.loadMore === true) void loadFeed('append'); + if (typeof params.hotspotId === 'string') + layer.setParams({ filters: { hotspot: params.hotspotId } }); + if (typeof params.reportId === 'string' && findReport(params.reportId)) { + select( + params.reportId, + Number.isInteger(params.pin) && params.pin > 0 ? params.pin : 0, + ); + if (params.focus === true) focusReport(selected()); + } + }, + getRowControls() { + return { + chips: [], + legend: [], + info: t( + 'Last 24 h from curated hotspots. Each report links to its publisher; places and categories are read from the text automatically and can be wrong. Side labels describe the source, not whether a report is true.', + ), + // A ready-to-render view for the News panel (src/ui/newsPanel.js), + // which takes no layer imports: labels and colours are resolved here. + feed: feedView(), + }; + }, + setRowControlsListener(value) { + listener = typeof value === 'function' ? value : null; + }, + getAnalystRecords() { + return []; + }, + getStats() { + return { + count: mapReports.filter((r) => r.pins.length && onMap(r)).length, + lastUpdate, + loading, + error, + ...(unavailable + ? { status: 'unavailable', sourceUnavailable: true } + : {}), + source: 'GEV news', + }; + }, + destroy() { + if (destroyed) return; + layer.disable(); + destroyed = true; + if (viewer && dataSource) viewer.dataSources?.remove(dataSource, true); + if (viewer && outlineSource) + viewer.dataSources?.remove(outlineSource, true); + dataSource = null; + outlineSource = null; + outlineParts.clear(); + outlineDisputed.clear(); + viewer = null; + listener = null; + runNavigation = null; + }, + }; + return layer; +} diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs new file mode 100644 index 000000000..5cc49938c --- /dev/null +++ b/src/layers/news/layer.test.mjs @@ -0,0 +1,255 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createNewsLayer } from './index.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const hotspotsPayload = { + hotspots: [ + { + id: 'ua-ru-war', + name: 'Russiaโ€“Ukraine war', + bbox: [22, 44, 42, 56], + focus: { lat: 48.6, lon: 35.5 }, + last24h: 3, + parties: ['UA', 'RU'], + }, + ], + categories: [], + lastRunAt: null, +}; +const report = (id, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://example.com/${id}`, + publishedAt: '2026-10-28T09:00:00.000Z', + publisher: 'Example', + side: 'UA', + category: 'strike', + hotspotId: 'ua-ru-war', + hotspotIds: ['ua-ru-war'], + severity: 2, + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true }, + pins: [{ lat: 50, lon: 36, place: 'Kharkiv', mappable: true }], + event: null, + media: [], + ...extra, +}); + +function fixture( + t, + { source: overrides = {}, height = 20_000_000, reduced = false } = {}, +) { + const calls = { feed: [], hotspots: 0, outline: 0 }; + const reports = [ + report('major', { + severity: 3, + event: { + size: 3, + severity: 3, + status: 'both-sides', + lastAt: '2026-10-28T09:30:00.000Z', + sides: [], + image: null, + }, + }), + report('minor', { severity: 1 }), + report('old', { publishedAt: '2026-10-26T09:00:00.000Z', severity: 3 }), + report('far', { + severity: 2, + pins: [{ lat: 31.5, lon: 34.4, place: 'Gaza', mappable: true }], + }), + ]; + const source = { + getHotspots: async () => { + calls.hotspots++; + return hotspotsPayload; + }, + getFeed: async (query) => { + calls.feed.push(query); + return { reports, nextBefore: null }; + }, + getOutline: async () => { + calls.outline++; + return { polygons: [], borders: [], disputed: [] }; + }, + ...overrides, + }; + const entries = []; + const overlayHost = { + setEntries: (_id, list) => entries.push(list), + setVisible() {}, + clearSource: () => entries.push([]), + }; + const flights = []; + const camera = { + positionCartographic: { height }, + positionWC: Cesium.Cartesian3.fromDegrees(36, 49, height), + moveEnd: new Cesium.Event(), + flyTo: (options) => flights.push(options), + }; + const viewer = { + camera, + scene: { requestRender() {}, preRender: new Cesium.Event() }, + dataSources: { add() {}, remove() {} }, + }; + const layer = createNewsLayer({ + source, + overlayHost, + now: () => NOW, + matchMedia: () => ({ matches: reduced }), + getViewCentre: () => ({ lat: 49, lon: 36 }), + }); + // The context store announces selections on `window`, which Node lacks. + const originalWindow = globalThis.window; + globalThis.window = new EventTarget(); + layer.init(viewer); + layer.attachShellServices({ runNavigation: (navigate) => navigate() }); + t.after(() => { + layer.destroy(); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + return { layer, viewer, camera, calls, entries, flights }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('the feed shows the last 24 h at significant and above, beacons when zoomed out', async (t) => { + const { layer, calls, entries } = fixture(t); + layer.enable(); + assert.equal(await layer.update(), true); + await settle(); + assert.equal(calls.hotspots, 1); + // The map read and the panel's feed read both ask for the last day only. + assert.ok(calls.feed.every((query) => query.today === true)); + const view = layer.getRowControls().feed; + assert.deepEqual( + view.items.map((item) => item.id), + ['major', 'far'], + ); + assert.deepEqual( + view.hotspots.map((chip) => [chip.id, chip.label, chip.active]), + [ + ['all', 'All', true], + ['ua-ru-war', 'Ukraineโ€“Russia', false], + ], + ); + const labels = entries.at(-1); + assert.ok(labels.some((entry) => entry.id === 'hotspot:ua-ru-war')); + assert.equal(layer.getStats().error, null); + // Zoomed out, the camera is too high for "In view". + assert.equal(view.inView.active, false); +}); + +test('choosing a card selects it and flies straight down from 150 km; reduced motion jumps', async (t) => { + const { layer, flights, entries } = fixture(t, { reduced: true }); + layer.enable(); + await layer.update(); + layer.setParams({ reportId: 'major', focus: true }); + assert.equal(flights.length, 1); + assert.equal(flights[0].duration, 0); + const at = Cesium.Cartographic.fromCartesian(flights[0].destination); + assert.ok(Math.abs(at.height - 150_000) < 1); + assert.ok(Math.abs(Cesium.Math.toDegrees(at.latitude) - 50) < 1e-6); + const card = entries.at(-1).find((e) => e.id === 'selected-report:major'); + assert.ok(card.details.includes('Source: example.com โ†—')); + assert.equal(layer.getRowControls().feed.selected.id, 'major'); + layer.setParams({ clear: true }); + assert.equal(layer.getRowControls().feed.selected, null); +}); + +test('the card toggle and the severity floor change what is shown, not what is read', async (t) => { + const { layer, entries, calls } = fixture(t, { height: 500_000 }); + layer.enable(); + await layer.update(); + const cards = () => + entries.at(-1).filter((entry) => entry.id.startsWith('event-card:')); + // No canvas in Node: cards need one, so none are painted, and nothing throws. + assert.deepEqual(cards(), []); + await settle(); + const reads = calls.feed.length; + layer.setParams({ filters: { cards: false, minSeverity: 0 } }); + assert.equal(calls.feed.length, reads); + assert.equal(layer.getRowControls().feed.filters.cards, false); + assert.deepEqual( + layer.getRowControls().feed.items.map((item) => item.id), + ['major', 'minor', 'far'], + ); +}); + +test('In view reads again only when the camera settles low in a new cell', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { layer, camera, calls } = fixture(t, { height: 1_000_000 }); + layer.enable(); + await layer.update(); + const reads = () => calls.feed.filter((q) => q.limit === 500).length; + const before = reads(); + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(799); + assert.equal(reads(), before); + t.mock.timers.tick(1); + await Promise.resolve(); + await Promise.resolve(); + const view = layer.getRowControls().feed.inView; + assert.equal(view.active, true); + // Gaza is beyond 500 km of the view centre; the old report is past 24 h. + assert.deepEqual( + view.items.map((item) => item.id), + ['major'], + ); + // Same cell: no new read. + const settled = reads(); + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(800); + assert.equal(reads(), settled); + // Up above 3,000 km, In view goes away. + camera.positionCartographic.height = 5_000_000; + camera.moveEnd.raiseEvent(); + t.mock.timers.tick(800); + assert.equal(layer.getRowControls().feed.inView.active, false); +}); + +test('a server without news is unavailable once and is not asked again', async (t) => { + const failure = Object.assign( + new Error('News feed not configured on this server'), + { + unavailable: true, + }, + ); + let reads = 0; + const { layer } = fixture(t, { + source: { + getHotspots: async () => { + reads++; + throw failure; + }, + }, + }); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(await layer.update(), true); + assert.equal(reads, 1); + const stats = layer.getStats(); + assert.equal(stats.sourceUnavailable, true); + assert.equal(stats.status, 'unavailable'); + assert.equal(layer.getRowControls().feed.error, failure.message); + // Turning the layer off and on asks again. + layer.disable(); + layer.enable(); + await layer.update(); + assert.equal(reads, 2); +}); + +test('a failed read keeps the layer on with an error; disable clears the overlay', async (t) => { + const { layer, entries } = fixture(t, { + source: { getFeed: async () => Promise.reject(new Error('boom')) }, + }); + layer.enable(); + assert.equal(await layer.update(), true); + assert.equal(layer.getStats().error, 'boom'); + assert.equal(layer.getStats().sourceUnavailable, undefined); + layer.disable(); + assert.deepEqual(entries.at(-1), []); + assert.deepEqual(layer.getRowControls().feed.items, []); +}); diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index f69cb54c0..d22d3e50b 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -31,6 +31,8 @@ const COCKPIT_REFUSALS = Object.freeze({ // ยซView coverageยป button); the cyclone layer flies to the selected storm. weather: msg('Exit cockpit to view weather coverage'), cyclone: msg('Exit cockpit to fly to a cyclone'), + // The News panel's cards and the news layer's hotspots fly to the event. + news: msg('Exit cockpit to fly to a news report'), // Voice track_entity (TRACKABLE_FAMILIES kinds in gevActions.js) and // frame_overhead, which frames the contacts around the current view. aircraft: msg('Exit cockpit to fly to an aircraft'), diff --git a/src/navigationPolicy.test.mjs b/src/navigationPolicy.test.mjs index 9826846ca..661ea7918 100644 --- a/src/navigationPolicy.test.mjs +++ b/src/navigationPolicy.test.mjs @@ -136,6 +136,7 @@ test('weather layers name their destination in the Cockpit refusal', () => { const expected = { weather: 'Exit cockpit to view weather coverage', cyclone: 'Exit cockpit to fly to a cyclone', + news: 'Exit cockpit to fly to a news report', }; for (const [noun, text] of Object.entries(expected)) { const s = spy(); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 2e0534a3a..d1a273455 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -20,6 +20,7 @@ import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; +import { createNewsSource } from '../layers/news/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -60,6 +61,10 @@ export function createStandaloneLayerSources( alpr: createAlprTileSource(), firms: createFirmsSource(), 's2-ghostmaps': createGhostMapsSource(), + // Same-origin /api/news: the local server proxies GEV_NEWS_ORIGIN and, + // without it, answers "not configured", which the layer shows as + // unavailable without retrying. + news: createNewsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 60128f8e0..1ac398c38 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -147,6 +147,15 @@ export class LayerBindings { fires.attachShellServices({ imageryHost: this.services.imageryHost }); this._weatherShellModules.push(fires); } + // News flies to a chosen report or hotspot through the same release. + const news = this._dataManager?.layers?.get('news')?.module; + if (typeof news?.attachShellServices === 'function') { + news.attachShellServices({ + runNavigation: (navigate) => + this.runImmediateNavigation('news', navigate), + }); + this._weatherShellModules.push(news); + } } _persistAwarenessSelection(event, cleared = false) { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 694d57066..83f4e7833 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -37,6 +37,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'gnss-integrity': msg('GNSS Integrity'), 'drone-airspace': msg('Prohibited & Restricted Airspace'), 's2-ghostmaps': msg('S2 GhostMaps'), + news: msg('News'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index b24e8be7b..39a840ab2 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -11,6 +11,7 @@ import { } from './layerNames.js'; export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; +import { createNewsPanel } from './newsPanel.js'; import { translateFeedError, translateStatusLine } from './statusText.js'; import { formatNumber, @@ -78,6 +79,7 @@ const PANEL_GROUPS = [ 'fire-perimeters', 'gnss-integrity', 's2-ghostmaps', + 'news', ], }, { @@ -310,6 +312,11 @@ export class LayerPanel { container?.ownerDocument?.getElementById?.('weather-panel-body'), setLayerParams: this.setLayerParams, }); + this._newsPanel?.destroy(); + this._newsPanel = createNewsPanel({ + container: container?.ownerDocument?.getElementById?.('news-panel-body'), + setLayerParams: this.setLayerParams, + }); this._mountRecentImagery(); this._renderToggles(); } @@ -345,6 +352,8 @@ export class LayerPanel { this._releaseBindings(); this._weatherPanel?.destroy(); this._weatherPanel = null; + this._newsPanel?.destroy(); + this._newsPanel = null; this._recentImageryPanel?.destroy(); this._recentImageryPanel = null; this._recentImageryFactory = null; @@ -409,10 +418,12 @@ export class LayerPanel { onLocaleChange(() => { this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); }), ); this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); } /** Present every row from the layers' current state and render them. */ @@ -641,6 +652,15 @@ export class LayerPanel { } this._paint(); this._refreshWeatherPanel(); + this._refreshNewsPanel(); + } + + /** Feed the News rail panel the view the news layer publishes while on. */ + _refreshNewsPanel() { + const layer = this.getAll().find(({ id }) => id === 'news'); + this._newsPanel?.update( + layer?.enabled ? this._rowControlsFor(layer.id)?.feed || null : null, + ); } _buildMetaText(layer) { diff --git a/src/ui/news/NewsCard.vue b/src/ui/news/NewsCard.vue new file mode 100644 index 000000000..46dbe5fb7 --- /dev/null +++ b/src/ui/news/NewsCard.vue @@ -0,0 +1,127 @@ + + + diff --git a/src/ui/news/NewsFeed.vue b/src/ui/news/NewsFeed.vue new file mode 100644 index 000000000..5d6fc8bce --- /dev/null +++ b/src/ui/news/NewsFeed.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/ui/newsPanel.js b/src/ui/newsPanel.js new file mode 100644 index 000000000..78fd6ea24 --- /dev/null +++ b/src/ui/newsPanel.js @@ -0,0 +1,73 @@ +import { h, render as renderVue } from 'vue'; +import NewsFeed from './news/NewsFeed.vue'; + +const LAYER_ID = 'news'; +const LOAD_MORE_MARGIN_PX = 160; +// Remounting must not reopen a panel the reader already collapsed. +const appearedPanels = new WeakSet(); +const set = (node, key, value) => { + if (node[key] !== value) node[key] = value; +}; + +/** + * The News rail panel (`#news-panel`): it renders the `feed` view the news + * layer publishes through its row controls, shows itself only while the + * layer is on, and acts only through setLayerParams. Near the bottom of its + * scroller it asks for the next older page, once per page. + */ +export function createNewsPanel({ container, setLayerParams = () => {} } = {}) { + const document = container?.ownerDocument; + if (!document?.createElement) return null; + const panel = container.closest?.('#news-panel'); + const count = panel?.querySelector('#news-panel-count'); + const onParams = (params) => + setLayerParams(LAYER_ID, params, { origin: 'user' }); + let view = null; + let destroyed = false; + let requestedAt = -1; + + function render() { + if (destroyed) return; + renderVue(h(NewsFeed, { view, onParams }), container); + const hidden = !view; + if (panel) set(panel, 'hidden', hidden); + if (count) set(count, 'textContent', String(view?.items.length ?? 0)); + if (!hidden && panel && !appearedPanels.has(panel)) { + appearedPanels.add(panel); + // First appearance opens the panel unless a stored or shared collapse + // choice exists (marked by the panel chrome when it restored the state). + const preference = panel.dataset?.collapsedPreference; + if ( + panel.classList?.contains('collapsed') && + (preference === undefined || preference === 'default') + ) + panel.querySelector('[data-collapse-target="news-panel"]')?.click(); + } + } + + const onScroll = () => { + if (!view?.hasMore || view.loading) return; + const remaining = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (remaining > LOAD_MORE_MARGIN_PX) return; + if (requestedAt === view.items.length) return; + requestedAt = view.items.length; + onParams({ loadMore: true }); + }; + container.addEventListener?.('scroll', onScroll, { passive: true }); + + return { + /** Show the layer's feed view, or hide the panel with null. */ + update(next) { + view = next || null; + render(); + }, + destroy() { + destroyed = true; + container.removeEventListener?.('scroll', onScroll); + renderVue(null, container); + if (panel) set(panel, 'hidden', true); + if (count) set(count, 'textContent', '0'); + }, + }; +} diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs new file mode 100644 index 000000000..71346fdc3 --- /dev/null +++ b/src/ui/newsPanel.test.mjs @@ -0,0 +1,223 @@ +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNewsPanel } from './newsPanel.js'; +import { + NEWS_CATEGORIES, + NEWS_SEVERITY, + categoryLabel, + feedCardView, + normalizeFilters, +} from '../layers/news/model.js'; + +const NOW = Date.parse('2026-10-28T10:00:00Z'); +const report = (id, extra = {}) => ({ + id, + title: `Report ${id}`, + url: `https://www.example.com/${id}`, + publishedAt: '2026-10-28T09:00:00.000Z', + publisher: 'Example', + side: 'UA', + category: 'strike', + severity: 2, + geo: { lat: 50, lon: 36, place: 'Kharkiv', mappable: true }, + pins: [{ lat: 50, lon: 36, place: 'Kharkiv', mappable: true }], + event: null, + media: [], + ...extra, +}); +const big = report('big', { + severity: 3, + titleEn: 'Strike on Kharkiv', + title: 'ะฃะดะฐั€ ะฟะพ ะฅะฐั€ะบะพะฒัƒ', + media: ['video'], + event: { + size: 4, + severity: 3, + status: 'both-sides', + lastAt: '2026-10-28T09:50:00.000Z', + sides: [ + { side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' }, + { side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' }, + ], + image: { url: 'https://a.test/p.jpg', publisher: 'A' }, + }, +}); + +function view(extra = {}) { + return { + filters: normalizeFilters(), + hotspots: [ + { id: 'all', label: 'All', count: 7, active: true }, + { id: 'ua-ru-war', label: 'Ukraineโ€“Russia', count: 7, active: false }, + ], + categories: Object.keys(NEWS_CATEGORIES).map((id) => ({ + value: id, + label: categoryLabel(id), + })), + severities: NEWS_SEVERITY.map((level, index) => ({ + value: index, + label: level.label, + color: level.color, + })), + items: [feedCardView(big, NOW), feedCardView(report('lone'), NOW)], + inView: { active: false, loading: false, error: null, items: [] }, + selected: null, + hasMore: true, + loading: false, + error: null, + ...extra, + }; +} + +function fixture() { + document.body.innerHTML = ` + `; + const panel = document.getElementById('news-panel'); + let opened = 0; + panel + .querySelector('[data-collapse-target]') + .addEventListener('click', () => { + opened++; + panel.classList.remove('collapsed'); + }); + const calls = []; + const news = createNewsPanel({ + container: document.getElementById('news-panel-body'), + setLayerParams: (id, params, options) => calls.push([id, params, options]), + }); + return { news, panel, calls, opened: () => opened }; +} + +test('the panel shows the last 24 h as event cards and opens once', () => { + const { news, panel, opened } = fixture(); + news.update(null); + assert.equal(panel.hidden, true); + news.update(view()); + assert.equal(panel.hidden, false); + assert.equal(opened(), 1); + assert.equal(document.getElementById('news-panel-count').textContent, '2'); + assert.equal( + panel.querySelector('.news-readout__window').textContent, + 'Last 24 h', + ); + assert.deepEqual( + [...panel.querySelectorAll('.data-toggle-chip[data-chip-id]')].map( + (chip) => chip.textContent, + ), + ['All 7', 'Ukraineโ€“Russia 7'], + ); + const [first, second] = panel.querySelectorAll( + '.news-readout__feed .news-card', + ); + assert.equal( + first.querySelector('.news-card__title').textContent, + 'Strike on Kharkiv', + ); + assert.equal( + first.querySelector('.news-card__note').textContent, + 'English headline by the model', + ); + assert.equal( + first.querySelector('.news-card__meta').textContent, + 'Strike ยท Kharkiv ยท 4 sources ยท 10 min', + ); + assert.equal( + first.querySelector('.news-card__marker').getAttribute('aria-label'), + 'Major', + ); + assert.equal( + first.querySelector('.news-card__sides').textContent, + 'Reported by UA sources ยท RU sources', + ); + assert.deepEqual( + [...first.querySelectorAll('.news-chip')].map((c) => c.textContent), + ['Reported by both sides', 'Video'], + ); + const img = first.querySelector('.news-card__thumb img'); + assert.equal(img.getAttribute('src'), 'https://a.test/p.jpg'); + assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer'); + assert.equal(first.querySelector('.news-card__credit').textContent, 'A'); + // A lone report has no picture. + assert.equal(second.querySelector('.news-card__thumb'), null); + // A later update does not reopen a panel the reader collapsed. + panel.classList.add('collapsed'); + news.update(view()); + assert.equal(opened(), 1); + news.destroy(); + assert.equal(panel.hidden, true); +}); + +test('a card click flies there; open, it links the source and closes', () => { + const { news, panel, calls } = fixture(); + news.update(view()); + panel.querySelector('.news-card__main').click(); + assert.deepEqual(calls.at(-1), [ + 'news', + { reportId: 'big', focus: true }, + { origin: 'user' }, + ]); + news.update(view({ selected: feedCardView(big, NOW) })); + const open = panel.querySelector('.news-card.is-open'); + const link = open.querySelector('.news-card__link'); + assert.equal(link.textContent, 'Source: example.com โ†—'); + assert.equal(link.getAttribute('href'), 'https://www.example.com/big'); + assert.equal(link.getAttribute('rel'), 'noopener noreferrer'); + assert.equal( + open.querySelector('.news-card__original').textContent, + 'ะฃะดะฐั€ ะฟะพ ะฅะฐั€ะบะพะฒัƒ', + ); + open.querySelector('.news-card__main').click(); + assert.deepEqual(calls.at(-1)[1], { clear: true }); + news.destroy(); +}); + +test('filters, In view and empty or failed reads', () => { + const { news, panel, calls } = fixture(); + news.update(view()); + assert.equal(panel.querySelector('[aria-label="In view"]'), null); + const severity = panel.querySelector('select[aria-label="Severity"]'); + assert.equal(severity.value, '2'); + severity.value = '3'; + severity.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1)[1], { filters: { minSeverity: 3 } }); + const [minor, cards] = panel.querySelectorAll('.news-toggle input'); + assert.equal(minor.checked, false); + assert.equal(cards.checked, true); + cards.checked = false; + cards.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1)[1], { filters: { cards: false } }); + panel.querySelector('[data-chip-id="ua-ru-war"]').click(); + assert.deepEqual(calls.at(-1)[1], { filters: { hotspot: 'ua-ru-war' } }); + + news.update( + view({ + inView: { active: true, loading: false, error: null, items: [] }, + items: [], + hasMore: false, + }), + ); + const section = panel.querySelector('[aria-label="In view"]'); + assert.match( + section.textContent, + /Nothing reported near here in the last 24 h/, + ); + assert.match( + panel.textContent, + /No news matches these filters in the last 24 h/, + ); + assert.equal(panel.querySelector('.news-readout__footer button'), null); + + news.update( + view({ items: [], error: 'News feed unavailable', hasMore: false }), + ); + assert.equal( + panel.querySelector('.news-readout__status').textContent, + 'News feed unavailable', + ); + news.destroy(); +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 439c1d77c..c5da55c43 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -17,6 +17,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'data-panel' }, { id: 'cctv-panel' }, { id: 'weather-panel' }, + { id: 'news-panel' }, { id: 'recent-imagery-panel' }, { id: 'street-level-panel' }, { id: 'radio-panel' }, @@ -41,6 +42,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'data-panel', 'cctv-panel', 'weather-panel', + 'news-panel', 'recent-imagery-panel', 'street-level-panel', 'scene-panel', diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..0c525a758 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -95,6 +95,7 @@ export class PanelLayoutController { this._ppToggles = document.getElementById('pp-toggles'); this._cctvPanel = document.getElementById('cctv-panel'); this._weatherPanel = document.getElementById('weather-panel'); + this._newsPanel = document.getElementById('news-panel'); this._recentImageryPanel = document.getElementById('recent-imagery-panel'); this._streetLevelPanel = document.getElementById('street-level-panel'); } @@ -191,6 +192,7 @@ export class PanelLayoutController { for (const panel of [ this._cctvPanel, this._weatherPanel, + this._newsPanel, this._recentImageryPanel, this._streetLevelPanel, ]) { @@ -206,6 +208,7 @@ export class PanelLayoutController { this._ppToggles, this._cctvPanel, this._weatherPanel, + this._newsPanel, this._recentImageryPanel, this._streetLevelPanel, globalContextPanel, diff --git a/src/ui/panelRailCatalog.js b/src/ui/panelRailCatalog.js index 7309c7f21..dbda77dcd 100644 --- a/src/ui/panelRailCatalog.js +++ b/src/ui/panelRailCatalog.js @@ -22,6 +22,7 @@ export const PANEL_RAIL_ITEMS = Object.freeze({ 'pp-toggles': { icon: 'tune', label: msg('DISPLAY') }, 'cctv-panel': { icon: 'videocam', label: msg('CCTV') }, 'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') }, + 'news-panel': { icon: 'public', label: msg('NEWS') }, 'recent-imagery-panel': { icon: 'satellite_alt', label: msg('RECENT IMAGERY'), diff --git a/src/ui/styles/news.css b/src/ui/styles/news.css new file mode 100644 index 000000000..9b70f2004 --- /dev/null +++ b/src/ui/styles/news.css @@ -0,0 +1,378 @@ +/* News rail panel (#news-panel): the last 24 hours as a doom-scroll of event + cards drawn with GEV's own tokens. Colour is + severity only (the layer sets --news-color per card); never a side. The + rail owns placement and height; only the body scrolls. */ +.news-panel-inner { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + max-height: 100%; + box-sizing: border-box; + padding: 14px; + border: 1px solid var(--glass-border); + border-radius: var(--panel-radius); + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.03) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; +} +#news-panel .panel-glow { + position: absolute; + inset: -18px; + background: radial-gradient( + ellipse at center, + rgba(0, 212, 255, 0.16) 0%, + transparent 65% + ); + filter: blur(16px); + opacity: 0.25; + pointer-events: none; +} +#news-panel .panel-header { + flex: 0 0 auto; +} +#news-panel-count { + color: var(--text-secondary); + font: 9px var(--font-mono); +} +#news-panel-body { + min-height: 0; + max-height: calc(var(--right-stack-max-height, 50svh) - 64px); + overflow-y: auto; + overscroll-behavior: contain; +} +#news-panel.collapsed #news-panel-body, +#news-panel[hidden] { + display: none !important; +} +#right-context-rail > #news-panel { + position: relative; + inset: auto; + flex: 0 0 auto; + width: 100%; + max-height: 100%; + pointer-events: auto; +} +#right-context-rail > #news-panel:not(.collapsed) { + display: flex; + flex-direction: column; + flex-shrink: 1; + min-height: 0; +} +#right-context-rail.layout-focus > #news-panel:not(.collapsed) { + flex: 0 1 var(--right-panel-allocated-height); + height: var(--right-panel-allocated-height); + min-height: 0; + max-height: var(--right-panel-allocated-height); +} +#right-context-rail.layout-focus + > #news-panel:not(.collapsed) + .news-panel-inner { + height: 100%; +} +#right-context-rail[data-rail-measuring] + > #news-panel:not(.collapsed) + > .news-panel-inner { + height: auto !important; +} +#right-context-rail #news-panel.collapsed { + width: var(--right-display-collapsed-width); + margin-inline-start: auto; +} +#right-context-rail #news-panel.collapsed .news-panel-inner { + height: 50px; + min-height: 50px; + padding-bottom: 10px; +} +@media (max-width: 720px) { + #right-context-rail #news-panel.collapsed { + width: 100%; + } +} +:root[data-ui-theme='cyber'] .news-panel-inner { + border-radius: 0; + clip-path: var(--cyber-panel-frame); + box-shadow: var(--cyber-panel-shadow); +} + +.news-readout { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + color: var(--text-primary); + font: 11px/1.4 var(--font-sans); +} +.news-readout__window, +.news-readout__head { + margin: 0; + color: var(--text-secondary); + font: 600 9px var(--font-mono); + letter-spacing: 0.12em; + text-transform: uppercase; +} +.news-readout__chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.news-readout__filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} +.news-filter { + min-width: 0; +} +.news-toggle { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 10px; + cursor: pointer; +} +.news-toggle input { + accent-color: var(--accent); +} +.news-readout__section { + display: flex; + flex-direction: column; + gap: 6px; +} +.news-readout__list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.news-readout__list.is-stale { + opacity: 0.5; +} +.news-readout__status, +.news-readout__empty { + color: var(--text-dim); + font: 10px var(--font-mono); +} +.news-readout__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.news-card { + border: 1px solid var(--glass-border); + border-inline-start: 3px solid var(--news-color, var(--glass-border)); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); +} +.news-card:hover, +.news-card.is-open { + border-color: var(--glass-border-hover); + border-inline-start-color: var(--news-color); + background: rgba(0, 212, 255, 0.06); +} +.news-card.is-unmapped .news-card__title { + color: var(--text-secondary); +} +.news-card__main { + display: grid; + grid-template-columns: 96px 1fr; + column-gap: 8px; + row-gap: 3px; + width: 100%; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} +.news-card__main > :not(.news-card__thumb) { + grid-column: 1 / -1; +} +.news-card__thumb ~ * { + grid-column: 2 !important; +} +.news-card__thumb { + position: relative; + grid-row: 1 / span 5; + width: 96px; + height: 64px; + overflow: hidden; + border-radius: 4px; + background: var(--bg-dark); +} +.news-card__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.news-card__credit { + position: absolute; + inset-inline-end: 0; + bottom: 0; + max-width: 100%; + overflow: hidden; + padding: 0 4px; + background: rgba(0, 0, 0, 0.6); + color: var(--text-primary); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} +.news-card__title { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + font-size: 12px; + font-weight: 600; + line-height: 1.3; +} +.news-card__note, +.news-card__sides { + color: var(--text-secondary); + font-size: 10px; +} +.news-card__meta { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--text-secondary); + font: 10px var(--font-mono); +} +.news-card__marker { + flex: none; + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + border: 1px solid rgba(0, 0, 0, 0.8); + border-radius: 50%; + background: var(--news-color); +} +.news-card__marker svg { + width: 9px; + height: 9px; + fill: none; + stroke: var(--bg-dark); + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.news-card__marker.solid svg { + fill: var(--bg-dark); + stroke: none; +} +.news-card__chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.news-chip { + padding: 1px 6px; + border: 1px solid var(--glass-border-hover); + border-radius: 999px; + color: var(--text-secondary); + font: 9px var(--font-mono); +} +.news-chip--official, +.news-chip--confirmed { + border-color: rgba(0, 212, 255, 0.45); + color: var(--text-primary); +} +.news-chip--claimed { + border-style: dashed; +} +.news-card__open { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 8px 8px; +} +.news-card__original, +.news-card__snippet { + margin: 0; + color: var(--text-primary); + font-size: 11px; +} +.news-card__original { + color: var(--text-secondary); +} +.news-card__others { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; + font-size: 10px; +} +.news-card__others a, +.news-card__link { + color: var(--accent); + text-decoration: none; + overflow-wrap: anywhere; +} +.news-card__others a:hover, +.news-card__link:hover { + text-decoration: underline; +} +.news-card__side { + margin-inline-end: 6px; + color: var(--text-secondary); +} +.news-card__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font: 10px var(--font-mono); +} +.news-legend { + color: var(--text-secondary); + font-size: 10px; +} +.news-legend summary { + cursor: pointer; +} +.news-legend p { + margin: 4px 0; +} +.news-legend__severity { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin: 4px 0; + padding: 0; + list-style: none; +} +.news-legend__severity li::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + margin-inline-end: 4px; + border-radius: 50%; + background: var(--news-color); +} +@media (prefers-reduced-motion: no-preference) { + .news-card, + .news-readout__list { + transition: + background var(--transition-fast), + border-color var(--transition-fast), + opacity var(--transition-fast); + } +} diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index c40665fe4..a0236afb2 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -36,6 +36,10 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
+