From 0c2e5727a5c1748323986eb901bc2cf31006b5a2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:52:44 -0500 Subject: [PATCH 001/181] feat(voice): reason-code viewport context escalation --- src/voice/realtimeViewport.js | 59 ++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index fd93996af..c41049d4c 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -11,6 +11,31 @@ export const VIEWPORT_MAX_PIXELS = 1200 * 900; // ~1.08 MP, matches the old 1200px-wide landscape budget export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; +/** + * Explain whether the existing Realtime path should escalate structured entity + * context to a viewport image. This is instrumentation vocabulary only; it + * preserves the current send policy exactly. + */ +export function viewportContextDecision(result, { channelOpen = true } = {}) { + if (result?.action !== 'get_entity_context') + return { send: false, reason: 'not-entity-context' }; + if (!channelOpen) return { send: false, reason: 'channel-closed' }; + const viewScale = result.scene?.basemap?.viewScale ?? null; + if (!shouldSendViewportImage(viewScale)) + return { send: false, reason: 'view-scale-not-local', viewScale }; + if (hasStructuredViewIdentity(result)) + return { + send: false, + reason: 'structured-identity-sufficient', + viewScale, + }; + return { + send: true, + reason: 'structured-identity-missing', + viewScale, + }; +} + export async function captureViewportImage() { const viewer = window.__godsEyeView?.viewer; const source = @@ -179,25 +204,31 @@ export class RealtimeViewport { } async sendVisualContextIfUseful(result) { - if ( - result?.action !== 'get_entity_context' || - !this.dc || - this.dc.readyState !== 'open' - ) - return false; - const viewScale = result.scene?.basemap?.viewScale; - if (!shouldSendViewportImage(viewScale)) return false; - if (hasStructuredViewIdentity(result)) return false; - const generation = this.generation; const channel = this.dc; + const decision = viewportContextDecision(result, { + channelOpen: channel?.readyState === 'open', + }); + this.debugLog?.('viewport_context.decision', decision); + if (!decision.send) return false; + + const generation = this.generation; const imageUrl = await this.capture(); + if (!imageUrl) { + this.debugLog?.('viewport_context.skipped', { + reason: 'capture-unavailable', + }); + return false; + } if ( - !imageUrl || generation !== this.generation || this.dc !== channel || channel.readyState !== 'open' - ) + ) { + this.debugLog?.('viewport_context.skipped', { + reason: 'session-changed-during-capture', + }); return false; + } // Keep at most one viewport screenshot in context. Images are the single // most expensive item (re-billed every turn they linger), so we proactively @@ -257,6 +288,10 @@ export class RealtimeViewport { 'client.viewport_context', ); this.lastViewportItemId = sent ? newItemId : null; + this.debugLog?.('viewport_context.result', { + sent, + reason: sent ? 'sent' : 'data-channel-send-failed', + }); return sent; } From 073ffedc1fbccfe64ed1b9bad6ce636dad23c859 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:52:54 -0500 Subject: [PATCH 002/181] feat(voice): route viewport decisions to diagnostics --- src/voice/realtimeController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index d0c9f1bae..285d0451b 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -82,6 +82,7 @@ export class GevRealtimeController extends RealtimeFacade { operations: { sendRealtimeEvent: (...args) => this.sendRealtimeEvent(...args), + debugLog: (...args) => this.debugLog(...args), }, }); this._diagnostics = new RealtimeDiagnostics({ From 3a40117e0619f477e7451cf57475670f9ccdcf99 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:53:12 -0500 Subject: [PATCH 003/181] test(voice): pin viewport escalation reasons --- src/voice/realtimeViewportPolicy.test.mjs | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/voice/realtimeViewportPolicy.test.mjs diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs new file mode 100644 index 000000000..b2d135597 --- /dev/null +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { viewportContextDecision } from './realtimeViewport.js'; + +const localContext = (overrides = {}) => ({ + action: 'get_entity_context', + scene: { basemap: { viewScale: 'local' } }, + ...overrides, +}); + +test('viewport context reasons preserve the existing escalation gate', () => { + assert.deepEqual(viewportContextDecision({ action: 'get_current_view_state' }), { + send: false, + reason: 'not-entity-context', + }); + assert.deepEqual( + viewportContextDecision(localContext(), { channelOpen: false }), + { send: false, reason: 'channel-closed' }, + ); + assert.deepEqual( + viewportContextDecision({ + action: 'get_entity_context', + scene: { basemap: { viewScale: 'regional' } }, + }), + { + send: false, + reason: 'view-scale-not-local', + viewScale: 'regional', + }, + ); + assert.deepEqual( + viewportContextDecision( + localContext({ + selected: { + id: 'earthquake:1', + layerId: 'earthquakes', + }, + }), + ), + { + send: false, + reason: 'structured-identity-sufficient', + viewScale: 'local', + }, + ); + assert.deepEqual(viewportContextDecision(localContext()), { + send: true, + reason: 'structured-identity-missing', + viewScale: 'local', + }); +}); + +test('nearby structured place identity avoids visual escalation too', () => { + assert.deepEqual( + viewportContextDecision( + localContext({ + scene: { + basemap: { + viewScale: 'local', + nearbyPlaces: [{ name: 'Austin' }], + }, + }, + }), + ), + { + send: false, + reason: 'structured-identity-sufficient', + viewScale: 'local', + }, + ); +}); From 86969f8a11812be9f8a2133cd4d6bbf74c365614 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:53:15 -0500 Subject: [PATCH 004/181] chore(format): adopt viewport policy tests --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..16041e033 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -603,6 +603,7 @@ "src/voice/control.js", "src/voice/realtimeBackend.js", "src/voice/realtimeBackend.test.mjs", + "src/voice/realtimeViewportPolicy.test.mjs", "src/voice/layerSummary.js", "build/application-html.js", "src/app/sourceSlot.js", From 5195012c7f3ea322c1717d2cf9078d898ae54936 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:55:51 -0500 Subject: [PATCH 005/181] debug(format): print viewport policy canonical output --- scripts/format.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/format.mjs b/scripts/format.mjs index 25311ba29..e1f2d4ca6 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -120,6 +120,15 @@ export async function formatAdoptedFiles(root, mode) { } const changed = files.filter(({ source, formatted }) => source !== formatted); + if (mode === '--check') { + const target = changed.find( + ({ name }) => name === 'src/voice/realtimeViewportPolicy.test.mjs', + ); + if (target) + console.error( + `FORMAT_EXPECTED BEGIN\n${target.formatted}FORMAT_EXPECTED END`, + ); + } if (mode === '--write') { for (const { file, formatted } of changed) await writeFile(file, formatted); } From 989e740d6efb9276f22f80a638d402776029a0c2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:57:43 -0500 Subject: [PATCH 006/181] style(voice): format viewport policy tests --- src/voice/realtimeViewportPolicy.test.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs index b2d135597..cd49cd3f0 100644 --- a/src/voice/realtimeViewportPolicy.test.mjs +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -10,10 +10,13 @@ const localContext = (overrides = {}) => ({ }); test('viewport context reasons preserve the existing escalation gate', () => { - assert.deepEqual(viewportContextDecision({ action: 'get_current_view_state' }), { - send: false, - reason: 'not-entity-context', - }); + assert.deepEqual( + viewportContextDecision({ action: 'get_current_view_state' }), + { + send: false, + reason: 'not-entity-context', + }, + ); assert.deepEqual( viewportContextDecision(localContext(), { channelOpen: false }), { send: false, reason: 'channel-closed' }, From cacbbc972bf8ada8314a2a62b7cdae1abefa6c0c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:57:48 -0500 Subject: [PATCH 007/181] chore: remove temporary formatter diagnostics --- scripts/format.mjs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/scripts/format.mjs b/scripts/format.mjs index e1f2d4ca6..25311ba29 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -120,15 +120,6 @@ export async function formatAdoptedFiles(root, mode) { } const changed = files.filter(({ source, formatted }) => source !== formatted); - if (mode === '--check') { - const target = changed.find( - ({ name }) => name === 'src/voice/realtimeViewportPolicy.test.mjs', - ); - if (target) - console.error( - `FORMAT_EXPECTED BEGIN\n${target.formatted}FORMAT_EXPECTED END`, - ); - } if (mode === '--write') { for (const { file, formatted } of changed) await writeFile(file, formatted); } From 050f68ac03ea65a27627517f8f72b4e814398568 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:37:22 -0500 Subject: [PATCH 008/181] test(voice): classify current-frame send and cancellation outcomes Keep viewport and pointer-crop capture unchanged, but expose static reason codes for unavailable frames, invalidated turn captures and retained image sends. No image or user text in diagnostics. --- src/voice/realtimeViewport.js | 16 +++++- src/voice/realtimeViewportPolicy.test.mjs | 61 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 9ab1b07a6..8ba50c73b 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -437,15 +437,24 @@ export class RealtimeViewport { const captured = await captureImage(); const imageUrl = typeof captured === 'string' ? captured : captured?.dataUrl || null; + if (!imageUrl) { + this.debugLog?.('viewport_context.skipped', { + reason: 'capture-unavailable', + }); + return false; + } if ( - !imageUrl || generation !== this.generation || request !== this.captureSeq || this.dc !== channel || channel.readyState !== 'open' || (typeof frame.valid === 'function' && !frame.valid()) - ) + ) { + this.debugLog?.('viewport_context.skipped', { + reason: 'capture-invalidated', + }); return false; + } // Keep at most one viewport screenshot in context. Images are the single // most expensive item (re-billed every turn they linger), so we proactively @@ -506,6 +515,9 @@ export class RealtimeViewport { cameraKey: this.readCameraKey(), } : null; + this.debugLog?.('viewport_context.result', { + reason: sent ? 'sent' : 'data-channel-send-failed', + }); return sent; } diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs index 5a361bcb5..3398f2a09 100644 --- a/src/voice/realtimeViewportPolicy.test.mjs +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -179,3 +179,64 @@ test('closed channel never captures pointer or full viewport', async () => { ); assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); }); + +test('capture failures and invalidated turns are reason-coded without sending', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + operations: { + sendRealtimeEvent: () => { + throw new Error('should not send unavailable image'); + }, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => null, + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'capture-unavailable', + ), + ); + let release; + viewport.capture = () => + new Promise((resolve) => { + release = resolve; + }); + const pending = viewport.sendVisualContextIfUseful(localContext()); + viewport.reset(); + release('data:image/jpeg;base64,AA=='); + assert.equal(await pending, false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'capture-invalidated', + ), + ); +}); + +test('successful retained send is logged without recording image contents', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + readCameraKey: () => 'camera:fixture', + operations: { + sendRealtimeEvent: () => true, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => 'data:image/jpeg;base64,AA==', + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); + assert.ok(diagnostics.some((item) => + item.event === 'viewport_context.result' && item.detail.reason === 'sent' + )); + assert.equal( + JSON.stringify(diagnostics).includes('data:image/jpeg'), + false, + ); +}); From d4980b1b77e2b2d00686dcdc7214df02bb99c779 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:39:42 -0500 Subject: [PATCH 009/181] test(voice): extract pinned formatting for #835 review repair (temporary) --- scripts/format.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/format.mjs b/scripts/format.mjs index 25311ba29..1828bd84e 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -141,6 +141,18 @@ if (import.meta.url === invoked) { if (mode === '--check' && result.changed.length) { for (const name of result.changed) console.error(`Needs formatting: ${name}`); + // Temporary CI-only formatter diagnostic. This commit is never promoted. + for (const name of result.changed.filter((file) => + ['src/voice/realtimeViewport.js', 'src/voice/realtimeViewportPolicy.test.mjs'].includes(file) + )) { + const file = path.join(fileURLToPath(new URL('../', import.meta.url)), name); + const output = await prettier.format(await readFile(file, 'utf8'), { + ...(await prettier.resolveConfig(file)), filepath: file, + }); + const encoded = Buffer.from(output).toString('base64'); + for (let pos = 0; pos < encoded.length; pos += 1000) + console.error(`PRETTIER-DATA|${name}|${pos}|${encoded.slice(pos, pos + 1000)}`); + } process.exitCode = 1; } else { console.log( From cb3b66e38b4c1f1d8ce2d00576efd2641d487b08 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:41:12 -0500 Subject: [PATCH 010/181] style(voice): apply exact Prettier 3.9.6 output to #835 Restore unchanged formatter script after ephemeral CI verification. Keep current-main-compatible reason instrumentation, pointer crop and capture cancellation tests. --- scripts/format.mjs | 12 ------- src/voice/realtimeViewport.js | 1 - src/voice/realtimeViewportPolicy.test.mjs | 44 +++++++++++++++-------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/scripts/format.mjs b/scripts/format.mjs index 1828bd84e..25311ba29 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -141,18 +141,6 @@ if (import.meta.url === invoked) { if (mode === '--check' && result.changed.length) { for (const name of result.changed) console.error(`Needs formatting: ${name}`); - // Temporary CI-only formatter diagnostic. This commit is never promoted. - for (const name of result.changed.filter((file) => - ['src/voice/realtimeViewport.js', 'src/voice/realtimeViewportPolicy.test.mjs'].includes(file) - )) { - const file = path.join(fileURLToPath(new URL('../', import.meta.url)), name); - const output = await prettier.format(await readFile(file, 'utf8'), { - ...(await prettier.resolveConfig(file)), filepath: file, - }); - const encoded = Buffer.from(output).toString('base64'); - for (let pos = 0; pos < encoded.length; pos += 1000) - console.error(`PRETTIER-DATA|${name}|${pos}|${encoded.slice(pos, pos + 1000)}`); - } process.exitCode = 1; } else { console.log( diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 8ba50c73b..18d06093f 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -112,7 +112,6 @@ export function viewportContextDecision(result, { channelOpen = true } = {}) { }; } - /** * Square source rectangle around a canvas point, clamped inside the canvas, * in drawing-buffer pixels, plus where the point lands in the output image. diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs index 3398f2a09..0d679559f 100644 --- a/src/voice/realtimeViewportPolicy.test.mjs +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { RealtimeViewport, viewportContextDecision } from './realtimeViewport.js'; +import { + RealtimeViewport, + viewportContextDecision, +} from './realtimeViewport.js'; const localContext = (overrides = {}) => ({ action: 'get_entity_context', @@ -99,7 +102,10 @@ test('pointer ground selects the #837 crop path, not a full viewport', () => { }, ); assert.deepEqual( - viewportContextDecision({ ...pointerContext, selected: { id: 'building' } }), + viewportContextDecision({ + ...pointerContext, + selected: { id: 'building' }, + }), { send: false, reason: 'pointer-identity-present', @@ -144,8 +150,14 @@ test('runtime dispatch preserves pointer crop and full image separation', async true, ); assert.equal(calls.filter((call) => call.kind === 'crop').length, 1); - assert.deepEqual(calls.find((call) => call.kind === 'crop').snapshot, pointer); - assert.equal(calls.find((call) => call.kind === 'crop').options.viewScale, 'city'); + assert.deepEqual( + calls.find((call) => call.kind === 'crop').snapshot, + pointer, + ); + assert.equal( + calls.find((call) => call.kind === 'crop').options.viewScale, + 'city', + ); assert.equal(calls.filter((call) => call.kind === 'viewport').length, 0); assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); @@ -157,10 +169,13 @@ test('runtime dispatch preserves pointer crop and full image separation', async false, ); assert.equal(calls.filter((call) => call.kind === 'viewport').length, 1); - assert.ok(calls.some((call) => - call.event === 'viewport_context.decision' && - call.detail.reason === 'pointer-ground-crop' - )); + assert.ok( + calls.some( + (call) => + call.event === 'viewport_context.decision' && + call.detail.reason === 'pointer-ground-crop', + ), + ); }); test('closed channel never captures pointer or full viewport', async () => { @@ -232,11 +247,12 @@ test('successful retained send is logged without recording image contents', asyn capture: async () => 'data:image/jpeg;base64,AA==', }); assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); - assert.ok(diagnostics.some((item) => - item.event === 'viewport_context.result' && item.detail.reason === 'sent' - )); - assert.equal( - JSON.stringify(diagnostics).includes('data:image/jpeg'), - false, + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.result' && + item.detail.reason === 'sent', + ), ); + assert.equal(JSON.stringify(diagnostics).includes('data:image/jpeg'), false); }); From 586ed4f8ea7f89ff24f82635419cee2e618b8856 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:04 +0100 Subject: [PATCH 011/181] 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 012/181] 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 013/181] 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 bbfb23a765e0bc2927993a4884317f38f92fa4bd Mon Sep 17 00:00:00 2001 From: BreezeDelegate <271538431+BreezeDelegate@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:04:43 +0200 Subject: [PATCH 014/181] fix(ci): run allocation gates on Node 26 --- changelog.d/766-node26-allocation-gates.md | 1 + docs/CURRENT-STATE.md | 17 +++++++------- scripts/qa-l9-matrix.mjs | 24 ++++++++++---------- scripts/run-unit-tests.mjs | 22 +++++++++--------- src/data/focusAllocations.test.mjs | 2 +- src/overlays/worldOverlayAllocation.test.mjs | 2 +- src/qaL9MatrixVerdicts.test.mjs | 3 ++- src/unitTestRunner.test.mjs | 23 ++++++++----------- 8 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 changelog.d/766-node26-allocation-gates.md diff --git a/changelog.d/766-node26-allocation-gates.md b/changelog.d/766-node26-allocation-gates.md new file mode 100644 index 000000000..a0fcb6a9f --- /dev/null +++ b/changelog.d/766-node26-allocation-gates.md @@ -0,0 +1 @@ +- Run the existing GC-bracketed allocation regression gates on Node 26 as well as Node 24. Keep the current byte budgets so CI catches allocation regressions on both supported runtimes (#39). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d49d92238..a7c2e2f7f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3148,9 +3148,9 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > style-switch persistence state. Browser/GPU performance comparison remains > operator-side; the final accounting records only comparable population > reductions and deterministic Node allocation/solve measurements. Those -> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits -> supported product runtimes on Node 24 or 26, while the allocation runner -> separately enforces Node 24 for these two calibrated probes. Each probe +> allocation gates are calibrated on both supported Node majors (24 and 26); +> `package.json` permits those same product runtimes, and the existing CI matrix +> executes the two calibrated probes on both entries. Each probe > compiles synchronously and discards explicit-GC > transition chunks before applying the unchanged byte ceilings. The unit > runner executes ordinary test files with Node's default parallelism, then @@ -4987,12 +4987,11 @@ and unreachable upstream (502/504) separately from road geometry. > **Reading `npm test` totals:** the count depends on the Node major. The two > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` -> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) only RUN on the -> calibrated Node 24 runtime; on any other major the runner skips both files and -> their 14 tests are absent from the total. A branch total quoted without its -> Node version is therefore not reproducible. As of the fly_route cinematic -> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on -> `main` + 26 route pins; the same tree on Node 24 reports 2,295. +> = 1 test, `src/overlays/worldOverlayAllocation.test.mjs` = 13) RUN on both +> supported Node 24 and Node 26. Unsupported majors may still skip those files +> unless `GEV_REQUIRE_ALLOCATION_GATE=1` is set; every current supported CI +> matrix entry is calibrated and therefore executes them. Historical totals recorded from unsupported Node 25 +> runs can therefore remain lower because their 14 allocation tests were skipped. - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 8709366de..d36bab9cb 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -239,11 +239,11 @@ function satisfiesEngines(version, range) { * re-resolve the interpreter from PATH and hand the gate a different Node than * the one the check believes it selected. * @param {string} version e.g. "v24.19.0" - * @returns {boolean} true only for a parseable Node 24. + * @returns {boolean} true for a parseable supported Node 24 or 26. */ function isCalibratedAllocationRuntime(version) { const [maj] = String(version || '').trim().replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10)); - return maj === 24; + return maj === 24 || maj === 26; } /** @@ -546,7 +546,7 @@ const check = (spec) => { CHECKS.push(spec); }; // ─── A · REPO GATES ─────────────────────────────────────────────────────── check({ - id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets are pinned to Node 24)', + id: 'A1', group: 'A', desc: 'Node runtime satisfies package.json engines (allocation budgets cover supported Node majors)', run: async () => { const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')); const range = pkg.engines?.node || '(unset)'; @@ -557,7 +557,7 @@ check({ if (satisfied) return pass(`node ${process.versions.node} satisfies "${range}"`); // An off-range runtime is an environment problem, not a product defect — // but it silently disables the allocation gate, so say so loudly. A3 then - // runs that gate under a discovered Node 24. + // runs that gate under a discovered calibrated Node 24 fallback. return env.node24 ? skip(`node ${process.versions.node} is OUTSIDE "${range}"; A3 runs the allocation gate under ${env.node24.label}, but prefer running the whole L9 pass on Node 24`, 'ENV') : fail(`node ${process.versions.node} is OUTSIDE "${range}" and no Node 24 runtime was found — the allocation gate cannot run at all`); @@ -579,13 +579,13 @@ check({ }); check({ - id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE (they silently skip off Node 24)', + id: 'A3', group: 'A', desc: 'Allocation microbenchmarks actually EXECUTE on supported Node majors', run: async () => { // npm test prints "[unit] SKIPPED n allocation microbenchmarks" and still - // exits 0 when the runtime is not Node 24. A green suite is therefore NOT - // proof the gate ran. Force it, under Node 24 when one is discoverable. + // exits 0 when the runtime is outside the calibrated supported majors. A green + // suite is therefore NOT proof the gate ran. Force it under a calibrated runtime. // - // ALWAYS invoke the Node 24 BINARY directly — never `npm test`. npm + // ALWAYS invoke the calibrated Node binary directly — never `npm test`. npm // re-resolves the interpreter from PATH and can land back on the system // Node even when THIS process is already 24: running the whole matrix under // `mise exec node@24.19.0 --`, the npm shell-out still re-execed system @@ -597,10 +597,10 @@ check({ const v = await sh(bin, ['--version'], { timeoutMs: 60000 }); const version = (v.out || '').trim(); if (!isCalibratedAllocationRuntime(version)) { - // run-unit-tests.mjs refuses to measure calibrated budgets off Node 24, + // run-unit-tests.mjs refuses to measure calibrated budgets off supported Node majors, // so a wrong binary means the gate never ran. That is this check failing // to establish its own claim — not evidence about the product. - return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 — the gate would refuse or silently skip, so this check verified nothing`); + return crash(`the runtime selected for the allocation gate (${label}) reports ${version || 'no parseable version'}, not Node 24 or 26 — the gate would refuse or silently skip, so this check verified nothing`); } const r = await sh(bin, [resolve(REPO_ROOT, 'scripts/run-unit-tests.mjs')], { timeoutMs: 600000, env: { GEV_REQUIRE_ALLOCATION_GATE: '1' }, @@ -611,9 +611,9 @@ check({ }; const [maj] = process.versions.node.split('.').map(Number); - if (maj === 24) return runGate(process.execPath, 'this runtime'); + if (maj === 24 || maj === 26) return runGate(process.execPath, 'this runtime'); if (env.node24) return runGate(env.node24.bin, env.node24.label); - return skip(`no Node 24 runtime found (running ${process.versions.node}); the gate SKIPS silently — install Node 24 and re-run`, 'OWNER-RUN'); + return skip(`no calibrated Node 24/26 runtime found (running ${process.versions.node}); the gate SKIPS silently — install a supported Node runtime and re-run`, 'OWNER-RUN'); }, }); diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 3712df28e..29c3e2b00 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -8,15 +8,16 @@ export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/overlays/worldOverlayAllocation.test.mjs', ]); -/** Whether this runtime matches the one the allocation budgets were calibrated on. */ +/** Whether this runtime matches a supported major the allocation budgets are calibrated on. */ export function isCalibratedAllocationRuntime(version = process.versions.node) { - return Number.parseInt(String(version).split('.')[0], 10) === 24; + const major = Number.parseInt(String(version).split('.')[0], 10); + return major === 24 || major === 26; } -/** Require the runtime on which allocation budgets were calibrated. */ -export function assertNode24AllocationRuntime(version = process.versions.node) { +/** Require a runtime on which allocation budgets are calibrated. */ +export function assertCalibratedAllocationRuntime(version = process.versions.node) { if (!isCalibratedAllocationRuntime(version)) { - throw new Error(`Allocation budgets require the calibrated Node 24 runtime; received ${version}`); + throw new Error(`Allocation budgets require calibrated Node 24 or 26; received ${version}`); } return version; } @@ -86,19 +87,18 @@ export function runUnitTests() { const parallelStatus = runTests(['--test', ...plan.parallel]); if (parallelStatus !== 0) return parallelStatus; - // The GC-bracketed budgets are calibrated on Node 24 and are meaningless on - // other allocators. A contributor's suite must stay green on any supported - // engine (package.json permits >=24), so uncalibrated runtimes skip the + // The GC-bracketed budgets are calibrated on every supported Node major. + // Unsupported runtimes may still run ordinary tests, so uncalibrated runtimes skip the // probes with a warning. Set GEV_REQUIRE_ALLOCATION_GATE=1 (pinned CI / // release batteries) to make an uncalibrated runtime a hard failure. if (!isCalibratedAllocationRuntime()) { if (process.env.GEV_REQUIRE_ALLOCATION_GATE === '1') { - assertNode24AllocationRuntime(); + assertCalibratedAllocationRuntime(); } console.warn( `[unit] SKIPPED ${ALLOCATION_TEST_FILES.length} allocation microbenchmarks: ` - + `budgets are calibrated for Node 24, running ${process.versions.node}. ` - + 'Run under Node 24 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', + + `budgets are calibrated for Node 24 and 26, running ${process.versions.node}. ` + + 'Run under Node 24 or 26 (or set GEV_REQUIRE_ALLOCATION_GATE=1 to fail instead).', ); return 0; } diff --git a/src/data/focusAllocations.test.mjs b/src/data/focusAllocations.test.mjs index 86f28db18..f58e5f121 100644 --- a/src/data/focusAllocations.test.mjs +++ b/src/data/focusAllocations.test.mjs @@ -13,7 +13,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs' */ test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => { if (!isCalibratedAllocationRuntime()) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const result = spawnSync( process.execPath, diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..70197097a 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -333,7 +333,7 @@ function runAllocationProbe(entryCount, profile = 'generic') { for (const workload of WORKLOADS) { test(`steady moving-source frames stay in budget ${workload.name}`, (t) => { if (!CALIBRATED_ALLOCATION_RUNTIME) { - return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`); + return t.skip(`allocation budgets are calibrated for Node 24 or 26; running ${process.versions.node}`); } const payload = runAllocationProbe(workload.entries, workload.profile); diff --git a/src/qaL9MatrixVerdicts.test.mjs b/src/qaL9MatrixVerdicts.test.mjs index 867e34410..2ec826e8d 100644 --- a/src/qaL9MatrixVerdicts.test.mjs +++ b/src/qaL9MatrixVerdicts.test.mjs @@ -127,9 +127,10 @@ test('an unevaluable engines range reports unknown rather than guessing', () => // A full pass run under `mise exec node@24 --` still re-execed system Node 25 // through npm; the forced gate refused ("received 25.6.1") and A3 reported it // as a product failure. A3 now invokes the binary directly and checks it. -test('only a parseable Node 24 counts as the calibrated allocation runtime', () => { +test('every supported Node major counts as a calibrated allocation runtime', () => { assert.equal(isCalibratedAllocationRuntime('v24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true, 'the v prefix is optional'); + assert.equal(isCalibratedAllocationRuntime('v26.3.0'), true); assert.equal(isCalibratedAllocationRuntime('v25.6.1'), false, 'the npm re-resolution case'); assert.equal(isCalibratedAllocationRuntime('v22.21.1'), false); for (const junk of ['', ' ', 'node', undefined, null]) { diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 80d9091e0..e206726e8 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -7,7 +7,7 @@ import { ALLOCATION_TEST_FILES, UNIT_TEST_ROOTS, allocationTestArgs, - assertNode24AllocationRuntime, + assertCalibratedAllocationRuntime, buildUnitTestPlan, discoverUnitTestFiles, isCalibratedAllocationRuntime, @@ -87,30 +87,27 @@ test('the runner discovers every test file in the repository', () => { } }); -test('allocation runtime calibration is explicit and pinned to Node 24', () => { - assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); +test('allocation runtime calibration covers both supported Node majors', () => { + assert.equal(assertCalibratedAllocationRuntime('24.19.0'), '24.19.0'); assert.throws( - () => assertNode24AllocationRuntime('22.23.1'), - /calibrated Node 24 runtime/, - ); - assert.throws( - () => assertNode24AllocationRuntime('26.0.0'), - /calibrated Node 24 runtime/, + () => assertCalibratedAllocationRuntime('22.23.1'), + /calibrated Node 24 or 26/, ); + assert.equal(assertCalibratedAllocationRuntime('26.0.0'), '26.0.0'); assert.equal(isCalibratedAllocationRuntime('24.19.0'), true); assert.equal(isCalibratedAllocationRuntime('22.23.1'), false); - assert.equal(isCalibratedAllocationRuntime('26.3.0'), false); + assert.equal(isCalibratedAllocationRuntime('26.3.0'), true); }); -test('npm test stays green on every supported engine, not only the calibrated one', () => { +test('npm test calibrates both supported engines while retaining off-range warnings', () => { // package.json wiring: `npm test` must invoke this runner, and the engines // range it advertises must not be narrower than what the runner tolerates. const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); assert.equal(pkg.scripts.test, 'node scripts/run-unit-tests.mjs'); const enginesNode = String(pkg.engines?.node || ''); assert.ok(enginesNode, 'engines.node must be declared'); - // The runner throws for uncalibrated runtimes ONLY behind the explicit - // opt-in env; by default it skips, so a supported non-24 engine cannot fail. + // Unsupported runtimes only skip probes without the explicit opt-in hard gate. + // Node 26 is calibrated and runs those probes with the same budgets. const runner = readFileSync(new URL('../scripts/run-unit-tests.mjs', import.meta.url), 'utf8'); assert.match(runner, /GEV_REQUIRE_ALLOCATION_GATE/); assert.match(runner, /SKIPPED .*allocation microbenchmarks/); From 98de41c94a5b36b1a8c01565c94f3c58f109d56f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 015/181] feat(street-level): "show me X on Street View" action, street_level view field and sl= links show_street_level (voice and typed) flies to a place and opens its nearest street-level photo from Google Street View, Mapillary, KartaView or Panoramax; Street View switches to Google 3D first. The view model gains a street_level field, share links carry sl=,,, and show_in_gods_eye_view can open a photo. A plain sl=google link only arms the point and waits for OPEN, so it never bills a key without a click. Embed pages show an open photo in an overlay that Esc closes. On-screen strings are translated into es, ru, zh-CN and zh-Hant. Covered by unit tests and three new steps in the Street Level browser suite. --- README.md | 2 +- SECURITY.md | 1 + changelog.d/1113-show-street-level.md | 14 + docs/CURRENT-STATE.md | 51 +- docs/TOOLS.md | 25 +- scripts/qa-street-level.mjs | 244 +++++++ scripts/qa-voice-routing.mjs | 1 + scripts/voice-bench/phrases.mjs | 8 + server/providers/openai/instructions.js | 3 +- server/providers/openai/toolDescriptions.js | 22 + src/app/embed.js | 95 ++- src/app/embed.test.mjs | 474 ++++++++++++- src/firstRunExperience.test.mjs | 3 +- src/i18n/locales/ar.json | 10 +- src/i18n/locales/de.json | 10 +- src/i18n/locales/es.json | 8 + src/i18n/locales/fr.json | 10 +- src/i18n/locales/it.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/pt.json | 10 +- src/i18n/locales/ru.json | 8 + src/i18n/locales/uk.json | 10 +- src/i18n/locales/zh-CN.json | 8 + src/i18n/locales/zh-Hant.json | 10 +- src/layers/streetLevel/index.js | 132 +++- src/layers/streetLevel/index.test.mjs | 671 ++++++++++++++++++ src/layers/streetLevel/lineCoverage.js | 20 + src/layers/streetLevel/lineCoverage.test.mjs | 64 ++ src/layers/streetLevel/policy.js | 8 + .../streetLevel/providers/google/index.js | 10 +- .../streetLevel/providers/google/policy.js | 2 +- .../providers/google/provider.test.mjs | 10 + .../streetLevel/providers/kartaview/index.js | 1 + .../streetLevel/providers/mapillary/index.js | 1 + .../streetLevel/providers/panoramax/index.js | 3 + .../providers/panoramax/provider.test.mjs | 6 + src/layers/streetLevel/registry.js | 4 +- src/layers/streetLevel/state.js | 2 + src/layers/streetLevel/viewerHost.js | 18 +- src/layers/streetLevel/viewerHost.test.mjs | 45 ++ src/locations.js | 6 + src/locations.test.mjs | 32 + src/radioMarkup.test.mjs | 9 +- src/streetLevelMarkup.test.mjs | 14 + src/tools/queries/share.js | 21 +- src/tools/queries/share.test.mjs | 101 +++ src/ui/applicationShell.js | 5 +- src/ui/setMapStackReason.test.mjs | 33 + src/ui/streetLevelControls.js | 44 ++ src/ui/streetLevelControls.test.mjs | 100 +++ src/ui/styles/embed.css | 16 + src/view/index.js | 171 ++++- src/view/index.test.mjs | 373 ++++++++++ src/voice/actionSchemas.js | 30 + src/voice/actionSchemas.test.mjs | 40 +- src/voice/deixis.js | 1 + src/voice/deixis.test.mjs | 7 + src/voice/gevActions.js | 364 ++++++++++ src/voice/gevActions.test.mjs | 533 ++++++++++++++ src/voice/layerManifest.js | 7 +- src/voice/layerManifest.test.mjs | 1 + src/voice/pointerReticle.js | 1 + src/voice/speech.js | 112 +++ src/voice/speech.test.mjs | 148 ++++ 64 files changed, 4147 insertions(+), 66 deletions(-) create mode 100644 changelog.d/1113-show-street-level.md create mode 100644 src/ui/setMapStackReason.test.mjs diff --git a/README.md b/README.md index 47cdf9535..cbc136b83 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic - **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director. - **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients. -Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook: +Thirty-two tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook: **🎥 Direct it** — drone-operator camera verbs: diff --git a/SECURITY.md b/SECURITY.md index 4423eead3..8c43bcee5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,6 +129,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp` - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. +- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link, are that client's own request and open the photo; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. ## Scope & expectations diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md new file mode 100644 index 000000000..36cf415df --- /dev/null +++ b/changelog.d/1113-show-street-level.md @@ -0,0 +1,14 @@ +- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a + `show_street_level` voice and typed action flies to the place, switches to + Google 3D when the source needs it, lights that source in Street Level, + turns the layer on and opens the nearest photo, reporting which step failed. + Views and share links carry the same request (`street_level`, `sl=`), so + `show_in_gods_eye_view` and a link can open a panorama at a place. Street + View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A + plain link with `sl=google` arms Street View and leaves the billed load to a + press of OPEN GOOGLE STREET VIEW; embedded views open it. The source the + action lights stays lit like a chip click. An unavailable map stack now + reports the controller's own reason instead of always asking for a Cesium + ion token. When nothing is found and a 360°, flat or SINCE filter the source + honours is on, the answer names the filter (Said Turksever, @asturksever, + #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..fe1083dd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -828,7 +828,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter. -Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. +Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 32 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. `createGevActionRunner` routes `control_cctv` enable/disable through the shared layer visibility lifecycle. Results distinguish successful changes from @@ -4328,6 +4328,53 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the three biggest instances; a picture on another instance shows only in the app. +**"Show me X on Street View."** The `show_street_level` assistant action +(voice and typed; `src/voice/gevActions.js`) is one call: it checks the +source has its key (a keyless source explains itself and changes nothing, as +a chip does), flies to the place at street framing and waits for arrival (a +name, coordinates, a pointed spot, a numbered item, or nothing for the view +centre), switches to the map the source needs (Google Street View: Google 3D), +lights that source through `setLayerParams` with origin `voice` (one source at +a time, kept in the share link like a chip click), turns the layer on and +calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, +`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `map`, +`provider`, `layer`, `key`, `imagery`; an empty `imagery` result names a +narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a +newer turn retires the lookup +(`openNearest`'s `signal` option), so nothing opens after the user moved on. +For a source without a coverage layer the lookup widens to 250 m +(`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); +clicks keep 50 m. The layer exposes `requiredMapStack(id)` and +`providerStatus(id)` for callers that must decide before the panel lists a +source, and `coverageLines(id)` (the lines a provider draws now, current and +stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, +which aims its line clicks at that geometry, waits for the asynchronous +ground primitives to be ready, and clicks in the same JavaScript turn as the +pick so a rebuilt frame cannot come between them. Views carry the same request as `street_level` (`src/view/index.js`: +source and point, defaulting to where the camera looks; a `google` source +brings `map: photoreal` and refuses another map; not with `follow`), links as +`sl=,,`; `show_in_gods_eye_view` accepts it, the globe panel +applies it through the action, and a plain link runs the action once restored +(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the +panel is hidden, so an open photo moves to an overlay on the globe +(`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full +screen, Esc leaves full screen first). A caller's abort is +honoured through the viewer's mount and load, not just the lookup, and only +the lookup that still owns the panel may change its spinner or error. A +passive link's photos stand down if anyone navigated (a gesture, a search, +the assistant) while the link waited behind its own marks. A plain +link never loads a photo from a source that bills every load +(`billedPerImage` on the provider; Google): the action runs with +`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's +point and radius, consumed only by the next OPEN press (`aim: +'screen-centre'`); any other lookup, open, close, disable, its source +switched off or its map leaving drops it), opens the panel and a toast +points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline +embed) and posted views open the photo; `?embed=1` on a published link only +arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`. +The look-at default applies below 20 km, at a pitch steeper than -5° and +within 10 km ahead; otherwise the camera's own point is used. + Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request @@ -5361,7 +5408,7 @@ and unreachable upstream (502/504) separately from road geometry. - **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. - **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn. -- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`. +- **Thirty-two tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`. > **Reading `npm test` totals:** the count depends on the Node major. The two > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 80fd80b13..2effba947 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -102,11 +102,26 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place name or coordinates). Links carry them in the `an` parameter, and the app draws them once the link has been restored. +A view can open street-level photos too: `street_level` names a source +(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for +whichever the panel has lit) and a point, which defaults to the spot the camera looks at. Street View +shows only on the Google 3D map, so a `google` source sets `map` to +`photoreal` and any other map is refused; a view cannot both follow an entity +and open photos. The `street-level` layer is turned on. Links carry it as +`sl=,,`; once the link has restored, the app runs its +`show_street_level` action, which flies to the point at street framing and +opens the nearest photo. A plain link with a `google` source stops before the +billed load: the panel is armed and OPEN GOOGLE STREET VIEW is one click away. +A view posted into the embedded app, or the panel page's own opening link, +opens the photo, since it is the client's own request; `?embed=1` on a +published link does not. Mapillary, KartaView and Panoramax links open the +photo at once; none of them bills per image. A link that follows an entity ignores `sl`. + Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a -`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A -tilted camera over an area looks at its center from behind; camera fields -given with an area override its framing. `resolveViewArguments` turns them -into a view. +`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and +`street_level`. A tilted camera over an area looks at its center from behind; +camera fields given with an area override its framing. `resolveViewArguments` +turns them into a view. ### The God's Eye View panel @@ -299,5 +314,5 @@ and the development server's internal routes. See SECURITY.md. | `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section | | `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | -| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow | +| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place | | `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel | diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 0dd841acf..12e350212 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -46,6 +46,15 @@ const VIEWPORT = { width: 1440, height: 900 }; /** Over downtown Sacramento, above the fixture photo line, looking down. */ const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 }; const DAY_MS = 86_400_000; +/** A Sacramento street with Street View, for the sl=google link step. */ +const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 }); +/** + * What a Street View lookup and load cost: the Maps JS API's + * SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles. + * Script loads are not counted. + */ +const STREET_VIEW_REQUEST = + /MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/; /** * Coverage for any street-zoom tile: a grid of 360°/flat, recent/old @@ -234,6 +243,28 @@ async function boot(page, url) { }); } +/** + * Open a share link as a fresh document. Changing only the hash of a live + * page and reloading races the app's share-link writer, which can put its own + * state back in the hash before the reload reads it. The page's fixtures (a + * CDP session on the page) carry over. + */ +async function bootLink(page, link) { + await page.goto('about:blank'); + await boot(page, link); +} + +/** The camera's position in degrees and metres. */ +const cameraAt = (page) => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + height: c.height, + }; + }); + /** Run `test(uiState, arg)` in the page until it is true. */ const uiUntil = (page, test, arg = null, timeout = 30_000) => page.waitForFunction( @@ -750,6 +781,219 @@ async function main() { await uiUntil(page, litAlone, 'mapillary'); await park(page); + const base = url.split('#')[0]; + await fixtureStep( + 'a link to street-level photos (sl=) opens the photo at its point once the link restores', + async () => { + const photo = fixture.images[0]; + // The camera sits ~330 m north of the photo: the lookup must use + // the link's point, not the view centre. + const cameraLat = Number((photo.lat + 0.003).toFixed(6)); + await bootLink( + page, + `${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`, + ); + await uiUntil( + page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'mapillary'); + assert.equal( + street.imageId, + photo.id, + `the photo at the link's point, not the one nearest the camera: ${street.imageId}`, + ); + assert.equal( + await page.evaluate(() => location.hash.includes('sl=')), + false, + 'the rewritten link no longer carries the one-shot request', + ); + }, + ); + + await fixtureStep( + 'the show_street_level action opens the nearest photo at a point without a pointer', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + const photo = fixture.images[0]; + const result = await page.evaluate( + (at) => + window.__godsEyeView.voiceCommands.runner('show_street_level', { + latitude: at.lat, + longitude: at.lon, + provider: 'mapillary', + fly: false, + }), + { lat: photo.lat, lon: photo.lon }, + ); + assert.equal( + result.ok, + true, + `action failed: ${result.stage} ${result.error}`, + ); + assert.equal(result.provider, 'mapillary'); + assert.ok( + fixture.images.some((image) => image.id === result.imageId), + `a fixture photo opened: ${result.imageId}`, + ); + assert.match(result.say || '', /^Mapillary at /); + }, + ); + + await step( + 'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN', + async ({ skipped }) => { + const point = STREET_VIEW_STREET; + // ~330 m north of the street: a flight would move the camera, and a + // press that ignored the armed point would open something else. + const cameraLat = Number((point.lat + 0.003).toFixed(6)); + const googleRequests = []; + const onRequest = (request) => { + if (STREET_VIEW_REQUEST.test(request.url())) + googleRequests.push(request.url()); + }; + page.on('request', onRequest); + try { + await bootLink( + page, + `${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`, + ); + await page.waitForFunction( + (want) => { + const c = + window.__godsEyeView.viewer.camera.positionCartographic; + return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005; + }, + { timeout: 60_000 }, + cameraLat, + ); + const googleKey = await page.evaluate(() => + Boolean(window.__GOOGLE_MAPS_API_KEY__), + ); + if (!googleKey) { + // Without a key the action stops at the key, before any flight, + // map switch or source change, and the direct call says why. + const direct = await page.evaluate( + (at) => + window.__godsEyeView.voiceCommands.runner( + 'show_street_level', + { + latitude: at.lat, + longitude: at.lon, + provider: 'google', + fly: false, + autoOpen: false, + }, + ), + point, + ); + assert.equal(direct.ok, false); + assert.equal( + direct.stage, + 'key', + `${direct.stage} ${direct.error}`, + ); + const after = await page.evaluate(() => { + const m = + window.__godsEyeView.dataManager.layers.get( + 'street-level', + ).module; + const u = m.getUIState(); + return { + stack: window.__godsEyeView.mapStackController.getActiveId(), + google: m.getParams().google === true, + armed: u.street.armed, + open: u.street.open, + }; + }); + assert.deepEqual( + after, + { stack: 'osm', google: false, armed: null, open: false }, + 'a keyless Street View link changes nothing', + ); + const still = await cameraAt(page); + assert.ok( + Math.abs(still.lat - cameraLat) < 0.0005, + `the camera did not fly: ${still.lat}`, + ); + assert.deepEqual(googleRequests, [], 'no Street View request'); + return; + } + const photoreal = await page.evaluate(() => + window.__godsEyeView.mapStackController.isStackAvailable( + 'photoreal', + ), + ); + if (!photoreal) return skipped('no Google 3D'); + await uiUntil( + page, + (u) => + u.street.armed !== null && + u.providers.some((p) => p.id === 'google' && p.on), + null, + 90_000, + ); + const beforePress = googleRequests.length; + const waiting = await getUI(page); + assert.equal( + waiting.street.open, + false, + 'nothing open without a click', + ); + assert.equal(waiting.street.imageId, null); + assert.deepEqual( + waiting.street.armed.point, + { lat: point.lat, lon: point.lon }, + 'armed at the link point', + ); + assert.equal( + beforePress, + 0, + 'no Street View request before the press', + ); + await expandPanel(page); + await press(page, '#sl-nearest-btn'); + await uiUntil( + page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'google'); + assert.ok( + googleRequests.length >= 1, + 'the press made the Street View request', + ); + const metres = Math.hypot( + (street.position.lat - point.lat) * 111_320, + (street.position.lon - point.lon) * + 111_320 * + Math.cos((point.lat * Math.PI) / 180), + ); + assert.ok( + metres <= 250, + `within 250 m of the link point: ${Math.round(metres)} m`, + ); + // Back to Mapillary: the keyless page restores the saved layer + // and expects its KEY REQUIRED. + await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); + await uiUntil(page, litAlone, 'mapillary'); + } finally { + page.off('request', onRequest); + } + }, + ); + await fixtureStep( 'keyless server (second page): KEY REQUIRED gates the controls', async () => { diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 0d89b525a..cb75b32ba 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -889,6 +889,7 @@ const SLOW_TOOLS = new Set([ 'annotate_map', 'fly_to_location', 'select_nearest_aircraft', + 'show_street_level', 'analyst_query', 'fly_route', 'frame_overhead', diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 9d1bcc8ba..6fb6f5de8 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -35,6 +35,14 @@ export const CORE_PHRASES = Object.freeze([ { phrase: 'Show me the whole earth', expect: 'zoom_to_globe' }, { phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] }, + // — street-level photos: one composite action, never fly + basemap — + { phrase: 'Show me the Eiffel Tower on Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && /eiffel/i.test(a.query || ''))] }, + { phrase: 'Street view of 10th Street in Sacramento', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google')] }, + { phrase: 'Show me Times Square on Mapillary', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'mapillary')] }, + { phrase: 'Switch to Google Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && !a.query)] }, + { phrase: 'Show me the Eiffel Tower', expect: 'fly_to_location' }, + { phrase: 'Turn on street level', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'street-level' && a.enabled === true)] }, + // — the satellites trap: data layer, never basemap — { phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } }, { phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] }, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index acf576d13..471ee21ad 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -28,7 +28,7 @@ function realtimeInstructions( 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', 'POINTING: pointer_context says what the cursor is on (pointing:: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.', - 'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.', + 'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, a searched fly_to_location, select_nearest_aircraft, show_street_level, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.', 'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.', 'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.', 'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.', @@ -55,6 +55,7 @@ function realtimeInstructions( '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', 'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', + 'STREET LEVEL: "X on Street View/Mapillary/KartaView/Panoramax", "street view of X" → show_street_level once, not fly_to_location; no place = view centre. Street View is never a basemap.', 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 23d5da9de..5225511be 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -65,6 +65,28 @@ export const ACTION_DESCRIPTIONS = { }, }, }, + show_street_level: { + description: + 'Fly to a place and open its nearest street-level photo; Street View switches to Google 3D (billed). No query: view centre.', + $position: 1, + parameters: { + properties: { + query: { + description: 'Place or address; "pointer" = under the cursor.', + $position: 1, + }, + provider: { + description: + 'google = Street View; others by name; omit: the lit source.', + $position: 1, + }, + referent: { + description: 'Numbered item n from the last result list.', + $position: 1, + }, + }, + }, + }, adjust_camera_zoom: { description: 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', diff --git a/src/app/embed.js b/src/app/embed.js index 21e685a91..a3dec5b84 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -13,12 +13,18 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; -import { annotationsFromParams, createView } from '../view/index.js'; +import { + annotationsFromParams, + createView, + followFromParams, + streetLevelFromParams, +} from '../view/index.js'; const EMBED_VIEW_MESSAGE = 'gev:view'; const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; const EMBED_READY_MESSAGE = 'gev:ready'; +const STREET_LEVEL_LAYER = 'street-level'; const FOLLOW_LAYERS = { aircraft: 'flights', military_aircraft: 'military', @@ -165,6 +171,26 @@ function flyTo(viewer, camera) { }); } +/** + * The `show_street_level` arguments for a view's street-level photos. A plain + * link (`autoOpen: false`) never loads a photo from a source that bills every + * load: the app arms the panel and the OPEN button is one click away. A view + * posted into the embedded app is the client's own request and opens. + */ +function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { + return { + latitude: streetLevel.lat, + longitude: streetLevel.lon, + ...(streetLevel.provider ? { provider: streetLevel.provider } : {}), + fly: true, + ...(autoOpen ? {} : { autoOpen: false }), + }; +} + +const STREET_LEVEL_PANEL_ID = 'street-level-panel'; +const ARMED_TOAST = + 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it'; + /** * Make the app show a view: style and map, exactly the view's layers, * annotations, the camera, and the followed entity. Each step runs through the @@ -198,6 +224,8 @@ export async function applyView( await act('set_layer_visibility', { layerId: layer.id, enabled: false }); } for (const layerId of view.layers) { + // The street-level action lights its source first, then the layer. + if (view.street_level && layerId === STREET_LEVEL_LAYER) continue; if (!dataManager.isEnabled(layerId)) await act('set_layer_visibility', { layerId, enabled: true }); } @@ -207,6 +235,18 @@ export async function applyView( await act('clear_annotations', {}); if (view.annotations.length) await act('annotate_map', { annotations: [...view.annotations] }); + if (view.street_level) { + // The photos frame the camera: the action flies to the point at street + // level, lights the source and opens the nearest photo. When it fails + // before flying, the view's own camera still applies. + const result = await act( + 'show_street_level', + streetLevelArgs(view.street_level), + ); + if (result?.ok !== true && !result?.location) + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + return steps; + } if (!view.follow) { steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); return steps; @@ -254,12 +294,56 @@ export function installViews({ windowRef = globalThis.window, }) { const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {}); - const linked = annotationsFromParams( - new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + const hashParams = new URLSearchParams( + String(location?.hash || '').replace(/^#/, ''), ); + // What the link itself asks for runs first, one thing at a time; posted + // views queue behind it. One failing step does not take the next with it. + // The camera's owner at restore: anyone who navigates while the link's + // steps wait (a gesture, a search, the assistant) takes it, and a passive + // link must not fly over them. + let generationAtRestore = null; + let queue = ready.then(() => { + generationAtRestore = shell._navigationGeneration; + }); + const navigatedSinceRestore = () => + Number.isFinite(generationAtRestore) && + shell._navigationGeneration !== generationAtRestore; + const linkStep = (work) => + queue.then(async () => { + if (signal?.aborted) return; + try { + await work(); + } catch { + /* reported by the action itself */ + } + }); + const linked = annotationsFromParams(hashParams); if (linked.length) - void ready.then(() => { - if (!signal?.aborted) void run('annotate_map', { annotations: linked }); + queue = linkStep(() => run('annotate_map', { annotations: linked })); + // A link to street-level photos opens them once the link has restored. A + // link anyone can publish stops short of a billed load and leaves it to a + // click; only the panel page's own link (inline embed) is the client's + // request. A link that follows an entity keeps the camera on it. + const streetLevel = followFromParams(hashParams) + ? null + : streetLevelFromParams(hashParams); + if (streetLevel) + queue = linkStep(async () => { + if (navigatedSinceRestore()) return; + const result = await run( + 'show_street_level', + streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }), + ); + // The restore window keeps the panel collapsed; OPEN must be in view. + // Embed mode hides the rail that holds it, so there the link only arms + // (a posted view is the way to open a photo in a frame). + if (result?.armed === true && !isEmbedded(location)) { + shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + explicit: true, + }); + shell._showToast?.(ARMED_TOAST); + } }); if (!isEmbedded(location)) return () => {}; @@ -278,7 +362,6 @@ export function installViews({ peer.postMessage(message, origin); }; // Views apply one at a time, in the order they arrive. - let queue = ready; const onMessage = (event) => { if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE) return; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5fa949342..f7aae019e 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -8,7 +8,7 @@ import { panelAwareVisibility, } from './embed.js'; import { LayerLifecycle } from '../data/lifecycle.js'; -import { createView } from '../view/index.js'; +import { createView, viewToParams } from '../view/index.js'; const fakeViewer = () => { const flights = []; @@ -393,3 +393,475 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it' /no cockpit view/, ); }); + +test('a view with street-level photos lets the action fly and open them', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder(); + const view = createView({ + camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set()), + run, + }); + assert.deepEqual( + calls.map(([name]) => name), + [ + 'set_map_stack', + 'stop_tracking', + 'clear_annotations', + 'show_street_level', + ], + ); + assert.deepEqual(calls.at(-1)[1], { + latitude: 38.5799, + longitude: -121.4937, + provider: 'google', + fly: true, + }); + assert.equal(viewer.flights.length, 0, 'the action frames the camera'); + assert.ok(steps.every((step) => step.ok)); + assert.equal(steps.at(-1).step, 'show_street_level'); +}); + +test('a link to street-level photos opens them after it restores', async () => { + const { calls, run } = recorder(); + const toasts = []; + const panels = []; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (...args) => panels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + [ + 'show_street_level', + { + latitude: 38.58, + longitude: -121.49, + provider: 'mapillary', + fly: true, + autoOpen: false, + }, + ], + ]); + assert.deepEqual( + [toasts, panels], + [[], []], + 'an opened photo needs no toast or panel nudge', + ); + // A failure (no key) is not an armed state either. + const failed = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (...args) => panels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: failed.run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual([toasts, panels], [[], []]); +}); + +test('a link that follows an entity keeps the camera: its photos are not opened', async () => { + const { calls, run } = recorder(); + const params = viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ); + params.set('sl', 'mapillary,1.00000,2.00000'); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '', hash: `#${params}` }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, []); +}); + +test('a failing link step does not take the next one with it', async () => { + const calls = []; + const run = async (name, args) => { + calls.push(name); + if (name === 'annotate_map') throw new Error('marks failed'); + return { ok: true, armed: false, ...args }; + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['annotate_map', 'show_street_level']); +}); + +test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => { + const toasts = []; + const panels = []; + const { calls, run } = recorder({ + show_street_level: () => ({ ok: true, armed: true }), + }); + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (id, collapsed, options) => + panels.push([id, collapsed, options]), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls[0][1].autoOpen, false); + assert.deepEqual(toasts, [ + 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it', + ]); + assert.deepEqual( + panels, + [['street-level-panel', false, { explicit: true }]], + 'the panel opens so OPEN is in view', + ); + // Adding ?embed=1 to a published link does not lift the gate: the page is + // still a plain top-level load. + const framedToasts = []; + const framedPanels = []; + const framed = recorder({ + show_street_level: () => ({ ok: true, armed: true }), + }); + const windowRef = new EventTarget(); + windowRef.document = { body: { classList: { add() {} } } }; + windowRef.parent = { postMessage() {} }; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + setCleanView() {}, + _showToast: (text) => framedToasts.push(text), + setPanelCollapsed: (...args) => framedPanels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: framed.run, + location: { + search: '?embed=1', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(framed.calls[0][1].autoOpen, false); + // In a frame the rail that holds OPEN is hidden: no nudge, no toast. + assert.deepEqual([framedToasts, framedPanels], [[], []]); + // Only the panel page's own link (inline embed) is the client's request. + globalThis.GEV_EMBED_INLINE = true; + try { + const inline = recorder({ show_street_level: () => ({ ok: true }) }); + const inlineWindow = new EventTarget(); + inlineWindow.document = { body: { classList: { add() {} } } }; + inlineWindow.postMessage = () => {}; + // The inline panel keeps rendering from a timer; give it inert clocks. + Object.assign(inlineWindow, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setTimeout: () => 1, + clearTimeout() {}, + setInterval: () => 1, + clearInterval() {}, + }); + const stop = installViews({ + shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: inline.run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef: inlineWindow, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal('autoOpen' in inline.calls[0][1], false); + stop(); + } finally { + delete globalThis.GEV_EMBED_INLINE; + } + // applyView (a posted view) opens too. + const posted = recorder(); + await applyView( + createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'google' }, + }), + { + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: posted.run, + }, + ); + assert.equal('autoOpen' in posted.calls.at(-1)[1], false); +}); + +test('a view whose photos fail before any flight still gets its camera', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set()), + run, + }); + assert.equal(viewer.flights.length, 1, 'the view flies itself'); + assert.deepEqual( + steps.map((step) => [step.step, step.ok]), + [ + ['set_map_stack', true], + ['stop_tracking', true], + ['clear_annotations', true], + ['show_street_level', false], + ['camera', true], + ], + ); + assert.ok(calls.some(([name]) => name === 'show_street_level')); + // A flight that happened (the action reports where) is not repeated. + const flown = fakeViewer(); + const { run: flownRun } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'imagery', + location: 'Somewhere', + error: 'no imagery', + }), + }); + await applyView(view, { + viewer: flown, + dataManager: fakeLayers(new Set()), + run: flownRun, + }); + assert.equal(flown.flights.length, 0); +}); + +test('a link runs its marks and photos in order, and posted views wait behind them', async () => { + const log = []; + let releaseStreetLevel = null; + let releaseMarks = null; + const run = async (name, args) => { + log.push(`start:${name}`); + if (name === 'annotate_map') + await new Promise((resolve) => { + releaseMarks = resolve; + }); + if (name === 'show_street_level') + await new Promise((resolve) => { + releaseStreetLevel = resolve; + }); + log.push(`end:${name}`); + return { + ok: true, + ...(name === 'show_street_level' ? { location: 'x' } : {}), + }; + }; + const windowRef = new EventTarget(); + windowRef.document = { body: { classList: { add() {} } } }; + // A framing page: answers go to it, not to the app's own window. + windowRef.parent = { + postMessage: (message) => log.push(`post:${message.type}`), + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '?embed=1', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // A view arrives while the link's photos are still opening. + windowRef.dispatchEvent( + Object.assign(new Event('message'), { + source: windowRef.parent, + origin: 'null', + data: { + type: 'gev:view', + id: 'v1', + view: { camera: { lat: 3, lon: 4 } }, + }, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const actions = () => log.filter((entry) => !entry.startsWith('post:')); + assert.deepEqual( + actions(), + ['start:annotate_map'], + 'the photos wait for the marks', + ); + releaseMarks(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(actions().slice(0, 3), [ + 'start:annotate_map', + 'end:annotate_map', + 'start:show_street_level', + ]); + assert.ok( + !log.includes('start:stop_tracking'), + 'the posted view waits for the link', + ); + releaseStreetLevel(); + for ( + let tick = 0; + tick < 20 && !log.includes('post:gev:view-applied'); + tick++ + ) + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'), + true, + ); + assert.ok(log.includes('post:gev:view-applied'), log.join(' ')); +}); + +test('an aborted install never runs the link photos', async () => { + const { calls, run } = recorder(); + const controller = new AbortController(); + controller.abort(); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + signal: controller.signal, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, []); +}); + +test('a passive link stands down when someone navigated while it waited behind its marks', async () => { + const calls = []; + let releaseMarks = null; + const shell = { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 7, + }; + const run = async (name) => { + calls.push(name); + if (name === 'annotate_map') + await new Promise((resolve) => { + releaseMarks = resolve; + }); + return { ok: true }; + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['annotate_map']); + shell._navigationGeneration = 8; // the recipient searched, or the assistant flew + releaseMarks(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + calls, + ['annotate_map'], + 'the link did not fly over the newer navigation', + ); + + // Unchanged navigation: the photos open as before. + const quiet = []; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 3, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: async (name) => { + quiet.push(name); + return { ok: true }; + }, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(quiet, ['annotate_map', 'show_street_level']); +}); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 87229e5c5..6603382c4 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -982,8 +982,9 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). + // show_street_level (street-level photos) is a later, separate tool. const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter( - (tool) => tool.name !== 'set_cyber_sonar', + (tool) => !['set_cyber_sonar', 'show_street_level'].includes(tool.name), ); const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') .parameters.properties.layout; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index e2e05348d..faed66510 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3222,5 +3222,13 @@ "Audio": "الصوت", "UTILITIES": "الأدوات", "Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم", - "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة" + "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", + "Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة", + "Captured {month}": "التُقطت في {month}", + "this spot": "هذا الموضع", + "the view centre": "مركز العرض", + "{source} at {place}": "{source} في {place}", + "Street level at {place}": "مستوى الشارع في {place}", + "Switching map": "جارٍ تبديل الخريطة", + "Opening imagery": "جارٍ فتح الصور" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0abaa4ddf..d98a33930 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2942,5 +2942,13 @@ "Audio": "Audio", "UTILITIES": "WERKZEUGE", "Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen", - "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien" + "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien", + "Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden", + "Captured {month}": "Aufgenommen im {month}", + "this spot": "diese Stelle", + "the view centre": "die Bildmitte", + "{source} at {place}": "{source} bei {place}", + "Street level at {place}": "Straßenebene bei {place}", + "Switching map": "Karte wird gewechselt", + "Opening imagery": "Bilder werden geöffnet" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 65997812d..8d530a7b4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -906,6 +906,8 @@ "Bike Share": "Bicis compartidas", "Cameras": "Cámaras", "Street Level": "A pie de calle", + "Press OPEN to load the photo": "Pulse ABRIR para cargar la foto", + "Captured {month}": "Capturada en {month}", "Mapped ALPR Cameras": "Cámaras ALPR mapeadas", "Data Centers": "Centros de datos", "Active Fires": "Incendios activos", @@ -1745,6 +1747,10 @@ "Mark {targets}": "Marcar: {targets}", "Fly to {place}": "Volar a {place}", "Nearest aircraft to {place}": "Aeronave más cercana a {place}", + "{source} at {place}": "{source} en {place}", + "Street level at {place}": "A pie de calle en {place}", + "this spot": "este punto", + "the view centre": "el centro de la vista", "Read the view": "Consultar la vista", "Check view state": "Comprobar el estado de la vista", "Frame {target}": "Encuadrar {target}", @@ -1752,6 +1758,8 @@ "Tracing outline": "Trazando contorno", "Looking up place": "Buscando lugar", "Flying": "Volando", + "Switching map": "Cambiando de mapa", + "Opening imagery": "Abriendo imágenes", "Turning on layer": "Activando capa", "Loading aircraft": "Cargando aeronaves", "Picking nearest": "Eligiendo la más cercana", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d1460e1d..5e7457e76 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3012,5 +3012,13 @@ "Audio": "Audio", "UTILITIES": "OUTILS", "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", - "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers" + "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers", + "Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo", + "Captured {month}": "Prise en {month}", + "this spot": "cet endroit", + "the view centre": "le centre de la vue", + "{source} at {place}": "{source} à {place}", + "Street level at {place}": "Niveau rue à {place}", + "Switching map": "Changement de carte", + "Opening imagery": "Ouverture des images" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d460440e2..cf642ae4e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3012,5 +3012,13 @@ "Audio": "Audio", "UTILITIES": "STRUMENTI", "Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno", - "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file" + "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file", + "Press OPEN to load the photo": "Premi APRI per caricare la foto", + "Captured {month}": "Scattata a {month}", + "this spot": "questo punto", + "the view centre": "il centro della vista", + "{source} at {place}": "{source} a {place}", + "Street level at {place}": "Vista stradale a {place}", + "Switching map": "Cambio mappa", + "Opening imagery": "Apertura delle immagini" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 237c55f6c..e41b78411 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2872,5 +2872,13 @@ "Audio": "オーディオ", "UTILITIES": "ユーティリティ", "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", - "Directions, drawing and your own files": "ルート案内、描画、自分のファイル" + "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", + "Press OPEN to load the photo": "「開く」を押して写真を読み込む", + "Captured {month}": "{month}撮影", + "this spot": "この地点", + "the view centre": "表示中心", + "{source} at {place}": "{place}の{source}", + "Street level at {place}": "{place}のストリートレベル", + "Switching map": "地図を切り替え中", + "Opening imagery": "画像を開いています" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 25f53f162..e6ccfd9f8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3012,5 +3012,13 @@ "Audio": "Áudio", "UTILITIES": "UTILITÁRIOS", "Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho", - "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos" + "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos", + "Press OPEN to load the photo": "Clique em ABRIR para carregar a foto", + "Captured {month}": "Capturada em {month}", + "this spot": "este ponto", + "the view centre": "o centro da vista", + "{source} at {place}": "{source} em {place}", + "Street level at {place}": "Nível da rua em {place}", + "Switching map": "Mudando de mapa", + "Opening imagery": "Abrindo imagens" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f3217b079..aa5f47c26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -915,6 +915,8 @@ "Bike Share": "Прокат велосипедов", "Cameras": "Камеры", "Street Level": "Уличные снимки", + "Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок", + "Captured {month}": "Снято: {month}", "Mapped ALPR Cameras": "Камеры ALPR на карте", "Data Centers": "Дата-центры", "Active Fires": "Активные пожары", @@ -1778,6 +1780,10 @@ "Mark {targets}": "Отметить {targets}", "Fly to {place}": "Перелёт: {place}", "Nearest aircraft to {place}": "Ближайший самолёт: {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Уличные снимки: {place}", + "this spot": "эта точка", + "the view centre": "центр вида", "Read the view": "Осмотреть видимую область", "Check view state": "Проверить состояние вида", "Frame {target}": "Показать в кадре: {target}", @@ -1785,6 +1791,8 @@ "Tracing outline": "Построение контура", "Looking up place": "Поиск места", "Flying": "Перелёт", + "Switching map": "Смена карты", + "Opening imagery": "Открытие снимков", "Turning on layer": "Включение слоя", "Loading aircraft": "Загрузка самолётов", "Picking nearest": "Выбор ближайших", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 720b0a7bd..2fbc3db48 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3082,5 +3082,13 @@ "Audio": "Аудіо", "UTILITIES": "ІНСТРУМЕНТИ", "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", - "Directions, drawing and your own files": "Маршрути, малювання та ваші файли" + "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", + "Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото", + "Captured {month}": "Знято: {month}", + "this spot": "це місце", + "the view centre": "центр карти", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Вуличні знімки: {place}", + "Switching map": "Перемикання карти", + "Opening imagery": "Відкриття знімків" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b9d8799c4..7978bff4d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1737,6 +1737,8 @@ "Bike Share": "共享单车", "Cameras": "摄像头", "Street Level": "街景", + "Press OPEN to load the photo": "点击“打开”加载照片", + "Captured {month}": "拍摄于 {month}", "Mapped ALPR Cameras": "地图标注 ALPR 摄像头", "Data Centers": "数据中心", "Active Fires": "活跃火点", @@ -2416,6 +2418,10 @@ "Fly to {place}": "飞往 {place}", "Fly to Location": "飞往地点", "Nearest aircraft to {place}": "距离 {place} 最近的飞机", + "{source} at {place}": "{place} 的 {source}", + "Street level at {place}": "{place} 的街景", + "this spot": "此处", + "the view centre": "视图中心", "Nearest aircraft to Here": "距离此处最近的飞机", "Layer on": "图层已开启", "Layer off": "图层已关闭", @@ -2428,6 +2434,8 @@ "Tracing outline": "正在绘制轮廓", "Looking up place": "正在查询地点", "Flying": "飞行中", + "Switching map": "正在切换地图", + "Opening imagery": "正在打开影像", "Turning on layer": "正在开启图层", "Loading aircraft": "正在加载飞机", "Picking nearest": "正在选取最近目标", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 06ce0fcaa..a0760dc1b 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2872,5 +2872,13 @@ "Right panels": "右側面板", "UTILITIES": "工具", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", - "Directions, drawing and your own files": "路線導航、繪製和你自己的文件" + "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", + "Press OPEN to load the photo": "點擊“打開”載入照片", + "Captured {month}": "拍攝於 {month}", + "this spot": "此處", + "the view centre": "視圖中心", + "{source} at {place}": "{place} 的 {source}", + "Street level at {place}": "{place} 的街景", + "Switching map": "正在切換地圖", + "Opening imagery": "正在打開影像" } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 1f7b0f7f0..409fdf384 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -21,6 +21,7 @@ import { AIM_AT_GROUND_MESSAGE, keyStatusLabel, NEAREST_RADIUS_M, + NEAREST_RADIUS_MAX_M, POSITION_PICK_ID, STREET_LEVEL_LAYER_ID, } from './policy.js'; @@ -157,6 +158,12 @@ export function createStreetLevelLayer({ const owner = state.providers.get(state.street.providerId); if (state.street.open && owner && !providerAvailable(owner)) layer.closeViewer(); + // An armed lookup for a source whose map left dies too. + const armedFor = state.street.armed?.providerIds?.map((id) => + state.providers.get(id), + ); + if (armedFor?.some((entry) => entry && !providerAvailable(entry))) + state.street.armed = null; for (const entry of state.providers.values()) { if (!entry.def.requiresMapStack) continue; if (state.enabled && entry.on && providerAvailable(entry)) @@ -275,8 +282,14 @@ export function createStreetLevelLayer({ } /** `frame: false` keeps the camera where it is (the user picked the spot). */ - function openImage(providerId, imageId, { frame = true } = {}) { + function openImage( + providerId, + imageId, + { frame = true, signal = null } = {}, + ) { + if (signal?.aborted) return Promise.resolve(false); abortNearest(); + state.street.armed = null; const entry = state.providers.get(providerId); if (entry && !providerAvailable(entry)) { state.street.loading = false; @@ -284,7 +297,7 @@ export function createStreetLevelLayer({ notify(); return Promise.resolve(false); } - return parts.viewerHost.open(providerId, imageId, { frame }); + return parts.viewerHost.open(providerId, imageId, { frame, signal }); } function unavailableReason(entry) { @@ -330,6 +343,9 @@ export function createStreetLevelLayer({ function switchProvider(entry, on) { if (entry.on === on) return; entry.on = on; + // A lookup armed for this source dies with it. + if (!on && state.street.armed?.providerIds?.includes(entry.def.id)) + state.street.armed = null; if (!state.enabled) return; if (on) activate(entry); else deactivate(entry); @@ -466,6 +482,37 @@ export function createStreetLevelLayer({ }, /** Registered providers, in chip order. */ providerIds: defs.map((def) => def.id), + /** The map stack a provider needs (`requiresMapStack`), or null for any. */ + requiredMapStack(providerId) { + const def = defs.find((entry) => entry.id === providerId); + return def?.requiresMapStack || null; + }, + /** The coverage lines a provider draws now (see `coverageLines`). */ + coverageLines(providerId) { + return state.providers.get(providerId)?.instance.coverageLines?.() ?? []; + }, + /** + * A provider's name and key state on any map (the panel lists a provider + * only on the map it needs). `configured` is null until its status call + * answers; `keyRequired` covers a missing or refused key. + */ + providerStatus(providerId) { + const entry = state.providers.get(providerId); + if (!entry) return null; + const stats = entry.instance.coverageStats(); + return { + id: entry.def.id, + name: entry.def.name, + on: entry.on, + configured: entry.status ? entry.status.configured === true : null, + keyRequired: stats.keyRequired === true || stats.keyRejected === true, + keyRejected: stats.keyRejected === true, + groundClick: entry.def.groundClick === true, + billed: entry.def.billedPerImage === true, + available: providerAvailable(entry), + error: stats.error || null, + }; + }, getSourceAvailability() { const available = defs.length > 0; return { @@ -612,13 +659,34 @@ export function createStreetLevelLayer({ /** * Open the nearest image of the active providers (or `providerIds`) near * a point, else the view centre, or with `aim: 'screen-centre'` the ground - * under the screen centre (sky: nothing). Providers without + * under the screen centre (sky: nothing). `radiusM` widens the search for + * providers that can (a geocoded landmark is rarely within 50 m of a + * street); clicks keep the default. A caller's `signal` retires the + * lookup: nothing opens after it fires. Providers without * `nearestImage` are skipped. */ async openNearest( point, - { providerIds = null, frame = true, aim = 'view' } = {}, + { + providerIds = null, + frame = true, + aim = 'view', + radiusM = null, + signal: callerSignal = null, + } = {}, ) { + if (callerSignal?.aborted) return false; + // An OPEN press after a link armed a lookup opens what the link asked; + // any other lookup is a new request and drops the armed one. + const armed = state.street.armed; + state.street.armed = null; + if (!point && aim === 'screen-centre' && armed) { + point = armed.point; + radiusM ??= armed.radiusM; + } + const radius = Number.isFinite(radiusM) + ? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM)) + : NEAREST_RADIUS_M; let view = point; if (!view && aim === 'screen-centre') { view = screenCentreGround(); @@ -637,6 +705,21 @@ export function createStreetLevelLayer({ const lookup = new AbortController(); nearestLookup = lookup; const { signal } = lookup; + const onCallerAbort = () => lookup.abort(); + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); + const retired = () => { + callerSignal?.removeEventListener('abort', onCallerAbort); + // Only the lookup that still owns the panel may touch it: a newer one + // has its own spinner and error. + if (nearestLookup !== lookup) return false; + nearestLookup = null; + // The caller moved on: the panel shows nothing of this lookup. + if (state.street.loading && !state.street.open) { + state.street.loading = false; + notify(); + } + return false; + }; state.street.loading = true; state.street.error = null; notify(); @@ -651,23 +734,35 @@ export function createStreetLevelLayer({ try { imageId = await entry.instance.nearestImage( { lat: view.lat, lon: view.lon }, - { signal }, + radius === NEAREST_RADIUS_M + ? { signal } + : { signal, radiusM: radius }, ); } catch (error) { + if (callerSignal?.aborted) return retired(); // Overtaken: whatever replaced it owns the panel, errors included. if (signal.aborted) return false; // One provider failing (no key, offline) must not hide the others. lastError = error; continue; } + if (callerSignal?.aborted) return retired(); if (signal.aborted) return false; if (!state.enabled) break; // Switched off, or its map left, while it looked: try the next. if (!imageId || !entry.on || !providerAvailable(entry)) continue; nearestLookup = null; - return openImage(entry.def.id, imageId, { frame }); + callerSignal?.removeEventListener('abort', onCallerAbort); + // The caller's signal still counts while the viewer mounts and loads. + return openImage(entry.def.id, imageId, { + frame, + signal: callerSignal, + }); } - if (nearestLookup === lookup) nearestLookup = null; + callerSignal?.removeEventListener('abort', onCallerAbort); + // Replaced while it looked: the panel is the newer lookup's. + if (nearestLookup !== lookup) return false; + nearestLookup = null; // Switched off while it looked: nothing to open, nothing to report. const stillOn = entries.some( (entry) => entry.on && providerAvailable(entry), @@ -679,11 +774,13 @@ export function createStreetLevelLayer({ lastError?.message || (point ? t('No street-level imagery within {radius} m of that point', { - radius: NEAREST_RADIUS_M, + radius, }) : t( 'No street-level imagery within {radius} m of the view centre', - { radius: NEAREST_RADIUS_M }, + { + radius, + }, )); state.street.loading = false; notify(); @@ -695,6 +792,23 @@ export function createStreetLevelLayer({ parts.viewerHost.close(); parts.clearSequences(); }, + /** + * Prepare a nearest lookup for the next OPEN press without running it: a + * link to a source that bills every load stops here. The press opens at + * this point with this radius; any other lookup, open or close, the + * source switched off, or its map leaving clears it. + */ + armNearest(point, { providerIds = null, radiusM = null } = {}) { + state.street.armed = { + point: + Number.isFinite(point?.lat) && Number.isFinite(point?.lon) + ? { lat: point.lat, lon: point.lon } + : null, + providerIds: Array.isArray(providerIds) ? [...providerIds] : null, + radiusM: Number.isFinite(radiusM) ? radiusM : null, + }; + notify(); + }, setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), resizeViewer: () => parts.viewerHost.resize(), }; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 6c79a52ca..a82dc53c5 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -12,6 +12,9 @@ import { fakeStreetLevelProvider as fakeProvider, } from '../../testSupport/streetLevelFakes.mjs'; import { setLocale } from '../../i18n/index.js'; +// Loaded up front: it brings Vue, which reads `document` once when it loads, +// and several tests here stand a bare fake document in for the page. +import { createGevActionRunner } from '../../voice/gevActions.js'; /** * A layer over `source` and `photoViewer`, initialised on a stand-in viewer @@ -1118,6 +1121,674 @@ test('switching source by params clears a failed lookup’s error; reapplying th assert.equal(second.getUIState().street.error, kept); }); +test('openNearest searches wider on request, never past the cap, and says how far it looked', async (t) => { + const { layer, fake } = await withStreetView(t, { nearest: () => null }); + layer.setProviderEnabled('google', true); + await settle(); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, radiusM: 250 }, + ); + assert.equal(fake.lookups.at(-1).radius, 250); + assert.equal( + layer.getUIState().street.error, + 'No street-level imagery within 250 m of that point', + ); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, radiusM: 5000 }, + ); + assert.equal(fake.lookups.at(-1).radius, 250, 'capped'); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.equal(fake.lookups.at(-1).radius, 50, 'a click keeps the default'); +}); + +test('a caller’s signal retires a nearest lookup: nothing opens after it fires', async (t) => { + let answer = null; + const { layer, fake } = await withStreetView(t, { + nearest: () => 'pano-a', + }); + const lib = fake.library; + const service = lib.StreetViewService.prototype.getPanorama; + lib.StreetViewService.prototype.getPanorama = function (request) { + return new Promise((resolve, reject) => { + answer = () => service.call(this, request).then(resolve, reject); + }); + }; + layer.setProviderEnabled('google', true); + await settle(); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: controller.signal }, + ); + await settle(); + controller.abort(); + answer?.(); + assert.equal(await pending, false); + const street = layer.getUIState().street; + assert.equal(street.open, false, 'the retired lookup opened nothing'); + assert.equal(street.loading, false, 'no stuck spinner'); + assert.equal(street.error, null, 'and reports no missing imagery'); + const refused = await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: controller.signal }, + ); + assert.equal(refused, false, 'an already-aborted signal asks nothing'); +}); + +test('providerStatus reports name, key state and billing on any map', async (t) => { + const { layer } = await withStreetView(t, { mapStack: 'osm' }); + const google = layer.providerStatus('google'); + assert.equal(google.name, 'Google Street View'); + assert.equal(google.billed, true, 'every Street View load is billed'); + assert.equal(google.available, false, 'not on this map'); + assert.equal(google.keyRequired, false); + assert.equal(layer.providerStatus('mapillary').billed, false); + assert.equal(layer.providerStatus('nope'), null); + assert.equal(layer.requiredMapStack('google'), 'photoreal'); + assert.equal(layer.requiredMapStack('mapillary'), null); +}); + +test('an armed lookup asks nothing until the OPEN press, then opens at its point and radius once', async (t) => { + const { layer, fake, mapillaryLookups } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + const before = fake.lookups.length; + layer.armNearest( + { lat: 38.57986, lon: -121.49372 }, + { providerIds: ['google'], radiusM: 250 }, + ); + await settle(); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The click gate: arming is free. No provider was asked, nothing opened. + assert.equal( + fake.lookups.length, + before, + 'no Google lookup before the press', + ); + assert.deepEqual(mapillaryLookups, [], 'no Mapillary lookup either'); + assert.equal(layer.getUIState().street.open, false); + assert.equal(layer.getUIState().street.loading, false); + assert.deepEqual(layer.getUIState().street.armed, { + point: { lat: 38.57986, lon: -121.49372 }, + providerIds: ['google'], + radiusM: 250, + }); + // The OPEN button: no point, aimed at the screen centre. + const opened = await layer.openNearest(undefined, { + providerIds: ['google'], + frame: false, + aim: 'screen-centre', + }); + assert.equal(opened, true); + assert.equal( + fake.lookups.length, + before + 1, + 'exactly one lookup for the press', + ); + const request = fake.lookups.at(-1); + assert.deepEqual( + request.location, + { lat: 38.57986, lng: -121.49372 }, + 'the armed point, not the screen centre', + ); + assert.equal(request.radius, 250); + assert.equal(layer.getUIState().street.armed, null, 'used once'); + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + layer.closeViewer(); + assert.equal(layer.getUIState().street.armed, null, 'closing clears it'); + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.deepEqual( + fake.lookups.at(-1).location, + { lat: 38.58, lng: -121.49 }, + 'an explicit point wins', + ); + assert.equal(layer.getUIState().street.armed, null); + // Only the OPEN press consumes the armed point: "street view here" after a + // pan is a new request at the view centre, and it drops the armed one. + layer.closeViewer(); + layer.armNearest( + { lat: 10, lon: 20 }, + { providerIds: ['google'], radiusM: 250 }, + ); + await layer.openNearest(null, { providerIds: ['google'], frame: false }); + const centre = fake.lookups.at(-1); + assert.notDeepEqual( + centre.location, + { lat: 10, lng: 20 }, + 'the view centre, not the armed point', + ); + assert.equal(centre.radius, 50, 'and the default radius'); + assert.equal(layer.getUIState().street.armed, null, 'dropped'); + // Opening any image, or switching the layer off, clears it too. + layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] }); + await layer.openImage('google', 'pano-a'); + assert.equal(layer.getUIState().street.armed, null, 'cleared by an open'); + layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] }); + layer.disable(); + assert.equal(layer.getUIState().street.armed, null, 'cleared by disable'); +}); + +test('a caller’s signal: an aborted one asks nothing, and a provider that ignores the abort still opens nothing', async (t) => { + const { layer, fake } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + const before = fake.lookups.length; + const done = new AbortController(); + done.abort(); + assert.equal( + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: done.signal }, + ), + false, + ); + assert.equal( + fake.lookups.length, + before, + 'an already-aborted signal asks nothing', + ); +}); + +test('a provider that ignores the abort and answers late still opens nothing', async (t) => { + let seen = null; + let release = null; + const slow = fakeProvider({ + nearestImage: async (_point, { signal } = {}) => { + seen = signal; + await new Promise((resolve) => { + release = resolve; + }); + return 'late-image'; + }, + }); + const { layer: ignoring } = await enabledLayer(t, [slow]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + ignoring.attachViewerHost(fakeHost()); + const controller = new AbortController(); + const pending = ignoring.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + await settle(); + controller.abort(); + assert.equal(seen?.aborted, true, 'the provider sees the abort'); + release(); + assert.equal(await pending, false); + const street = ignoring.getUIState().street; + assert.equal(street.open, false, 'the late answer opened nothing'); + assert.equal(street.loading, false, 'no stuck spinner'); + assert.equal(street.error, null); +}); + +test('providerStatus reports a keyless source on any map', async (t) => { + const keyless = await withStreetView(t, { + mapStack: 'osm', + mapillaryKeyless: true, + }); + await settle(); + const mapillary = keyless.layer.providerStatus('mapillary'); + assert.equal(mapillary.configured, false); + assert.equal(mapillary.keyRequired, true); + assert.equal(mapillary.keyRejected, false); +}); + +test('providerStatus reports a refused key on any map', async (t) => { + const refused = await withStreetView(t, { mapStack: 'osm' }); + refused.loader.refuseKey(); + await settle(); + const google = refused.layer.providerStatus('google'); + assert.equal( + google.keyRequired, + true, + 'a refused key gates like a missing one', + ); + assert.equal(google.keyRejected, true); + assert.equal(google.available, false, 'still not on this map'); +}); + +test('an armed lookup dies with its source: switched off, or its map gone', async (t) => { + const { layer, stack } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + layer.setProviderEnabled('google', false); + assert.equal( + layer.getUIState().street.armed, + null, + 'switching the source off clears it', + ); + layer.setProviderEnabled('google', true); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + // Lighting Mapillary instead (one source at a time) turns Google off too. + layer.setProviderEnabled('mapillary', true); + assert.equal( + layer.getUIState().street.armed, + null, + 'another source lit clears it', + ); + layer.setProviderEnabled('google', true); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + // A map-stack notify that keeps Google 3D (a silent re-settle) keeps it. + stack.switchTo('photoreal'); + assert.notEqual( + layer.getUIState().street.armed, + null, + 'a notify on the same map keeps it', + ); + stack.switchTo('osm'); + assert.equal( + layer.getUIState().street.armed, + null, + 'leaving Google 3D clears it', + ); + // Arming notifies, so a panel can show the state. + let notified = 0; + const unsubscribe = layer.subscribe(() => notified++); + stack.switchTo('photoreal'); + await settle(); + const before = notified; + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + await settle(); + assert.ok(notified > before, 'subscribers hear about the armed lookup'); + unsubscribe(); +}); + +test('a lookup armed with no point opens under the middle of the screen on the press', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ ...point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + // Registered before the layer exists, so a throwing create() still + // restores the document; destroy() runs first, while the stub is in place. + let layer = null; + t.after(() => { + layer?.destroy(); + globalThis.document = saved; + }); + layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + layer.armNearest(null, { providerIds: [provider.id], radiusM: 250 }); + assert.equal( + layer.getUIState().street.armed.point, + null, + 'no point stored, not 0,0', + ); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + assert.equal(lookups.length, 1); + assert.ok( + Math.abs(lookups[0].lat - 38.5799) < 1e-9, + 'the screen centre, not Null Island', + ); + assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); + assert.equal(lookups[0].radiusM, 250, 'with the armed radius'); +}); + +test('the action over the real layer: a billed link arms without a lookup, and one press makes exactly one at the point', async (t) => { + const { layer, fake, viewer } = await withStreetView(t); + // The runner wires pointer tracking to the clock and camera. + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'photoreal' }, + setMapStack: async () => ({ ok: true, activeStack: 'photoreal' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + const before = fake.lookups.length; + const armed = await runner('show_street_level', { + latitude: 38.57986, + longitude: -121.49372, + provider: 'google', + fly: false, + autoOpen: false, + }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.equal(armed.providerName, 'Google Street View'); + await settle(); + assert.equal(fake.lookups.length, before, 'arming made no Google request'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['google'], + ); + assert.equal(layer.getUIState().street.open, false); + // The OPEN button. + assert.equal( + await layer.openNearest(undefined, { + providerIds: ['google'], + frame: false, + aim: 'screen-centre', + }), + true, + ); + assert.equal(fake.lookups.length, before + 1, 'one request for the press'); + assert.deepEqual(fake.lookups.at(-1).location, { + lat: 38.57986, + lng: -121.49372, + }); + assert.equal(fake.lookups.at(-1).radius, 250); + assert.equal(layer.getUIState().street.imageId, 'pano-a'); + // The same call without the gate opens at once, with one request. + layer.closeViewer(); + const opened = await runner('show_street_level', { + latitude: 38.57986, + longitude: -121.49372, + provider: 'google', + fly: false, + }); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.imageId, 'pano-a'); + assert.equal(fake.lookups.length, before + 2); +}); + +test('coverageLines hands back what a provider draws, and nothing for providers without lines', async (t) => { + const drawing = fakeProvider(); + const create = drawing.create; + drawing.create = (context) => ({ + ...create(context), + coverageLines: () => [ + { + id: 's1', + parts: [ + [ + [1, 2], + [1, 2.001], + ], + ], + }, + ], + }); + const { layer } = await enabledLayer(t, [drawing]); + assert.deepEqual(layer.coverageLines(drawing.id), [ + { + id: 's1', + parts: [ + [ + [1, 2], + [1, 2.001], + ], + ], + }, + ]); + assert.deepEqual(layer.coverageLines('nope'), []); +}); + +test('coverageLines is empty for a provider that draws no coverage', async (t) => { + const { layer } = await enabledLayer(t, [fakeProvider()]); + assert.deepEqual(layer.coverageLines(fakeProvider().id), []); +}); + +test('openNearest keeps honouring the caller after the lookup: an abort while the viewer loads opens nothing', async (t) => { + let releaseMount = null; + const provider = fakeProvider({ nearestImage: async () => 'img-1' }); + const create = provider.create; + provider.create = (context) => { + const instance = create(context); + const mount = instance.viewer.mount; + instance.viewer.mount = async (...args) => { + await new Promise((resolve) => { + releaseMount = resolve; + }); + return mount(...args); + }; + return instance; + }; + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + for (let i = 0; i < 20 && !releaseMount; i++) await settle(); + assert.ok(releaseMount, 'the lookup answered and the viewer is mounting'); + controller.abort(); + releaseMount(); + assert.equal(await pending, false); + assert.deepEqual(provider.calls.open, [], 'the photo was never opened'); + assert.equal(layer.getUIState().street.open, false); + assert.equal(layer.getUIState().street.loading, false); +}); + +test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => { + const answers = []; + const provider = fakeProvider({ + nearestImage: (_point, { signal } = {}) => + new Promise((resolve, reject) => { + answers.push(resolve); + signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }); + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + const older = new AbortController(); + const first = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: older.signal }, + ); + await settle(); + // A newer request replaces it, and its caller withdraws the old one too. + const second = layer.openNearest({ lat: 3, lon: 4 }, { frame: false }); + older.abort(); + assert.equal(await first, false); + assert.equal( + layer.getUIState().street.loading, + true, + 'the newer lookup is still loading', + ); + answers.at(-1)(null); + assert.equal(await second, false); + assert.equal(layer.getUIState().street.loading, false); + assert.match( + layer.getUIState().street.error, + /No street-level imagery/, + 'the newer lookup reports its own result', + ); +}); + +test('show_street_level opens a KartaView photo, and a plain link opens it at once since it is not billed', async (t) => { + const mapillary = fakeProvider(); + let nearest = 'kv-1'; + const kartaview = fakeProvider({ + id: 'kartaview', + pickPrefix: 'kv:', + nearestImage: async () => nearest, + }); + kartaview.name = 'KartaView'; + kartaview.label = 'KARTAVIEW'; + kartaview.defaultOn = false; + // As the real provider: 360° / flat and SINCE (the setFilter log stays in + // the fake's closure). + kartaview.filters = Object.freeze(['pano', 'since']); + const { layer, viewer } = await enabledLayer(t, [mapillary, kartaview]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async () => ({ ok: false, error: 'no map switch expected' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + // The way a plain sl=kartaview link calls it: autoOpen false. + const result = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + autoOpen: false, + }); + assert.equal(result.ok, true, result.error); + assert.equal(result.armed, undefined, 'not armed: KartaView bills nothing'); + assert.equal(result.provider, 'kartaview'); + assert.equal(result.providerName, 'KartaView'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['kartaview'], + ); + assert.deepEqual(kartaview.calls.open, ['kv-1']); + assert.equal(layer.providerStatus('kartaview').billed, false); + assert.equal(layer.requiredMapStack('kartaview'), null); + + // With 360° only on, an empty search names the filter, not just "no imagery". + layer.closeViewer?.(); + layer.setParams({ pano: 'pano' }); + nearest = null; + const empty = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + }); + assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']); + assert.equal(empty.filter?.pano, 'pano'); + assert.match(empty.error, /with the 360° filter on$/); + layer.setParams({ pano: 'all' }); + const plain = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + }); + assert.equal(plain.ok, false); + assert.equal(plain.filter, undefined, 'no filter, no hint'); + assert.doesNotMatch(plain.error, /filter/); +}); + +test('show_street_level switches from KartaView to Panoramax and opens its photo at once, unbilled', async (t) => { + const mapillary = fakeProvider(); + const kartaview = fakeProvider({ id: 'kartaview', pickPrefix: 'kv:' }); + kartaview.name = 'KartaView'; + kartaview.defaultOn = false; + const panoramax = fakeProvider({ + id: 'panoramax', + pickPrefix: 'px:', + nearestImage: async () => 'px-1', + }); + panoramax.name = 'Panoramax'; + panoramax.label = 'PANORAMAX'; + panoramax.defaultOn = false; + const { layer, viewer } = await enabledLayer(t, [ + mapillary, + kartaview, + panoramax, + ]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + layer.setParams({ mapillary: false, kartaview: true, panoramax: false }); + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async () => ({ ok: false, error: 'no map switch expected' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + // The way a plain sl=panoramax link calls it: autoOpen false. + const result = await runner('show_street_level', { + latitude: 48.8874, + longitude: 2.3495, + provider: 'panoramax', + fly: false, + autoOpen: false, + }); + assert.equal(result.ok, true, result.error); + assert.equal(result.armed, undefined, 'not armed: Panoramax bills nothing'); + assert.equal(result.providerName, 'Panoramax'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['panoramax'], + 'KartaView switched off', + ); + assert.deepEqual(panoramax.calls.open, ['px-1']); + assert.deepEqual(kartaview.calls.open, [], 'nothing asked of KartaView'); + assert.equal(layer.providerStatus('panoramax').billed, false); + assert.equal(layer.requiredMapStack('panoramax'), null); +}); + test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => { const { presentStreetLevelPanel } = await import('../../ui/streetLevelPresentation.js'); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index bab1af46d..b71fe7cd7 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -531,6 +531,25 @@ export function createLineCoverage({ }; } + /** + * The sequence lines drawn now, as `[lon, lat]` parts per sequence: for + * diagnostics and the browser gate, which aims its clicks at them instead + * of scanning pixels: what each tile drew (`attachPrimitive` keeps it in + * `entry.sequences`). Read-only copies. + */ + function drawnLines() { + const lines = []; + for (const entry of state.coverage.tiles.values()) + for (const sequence of entry.sequences.values()) + lines.push({ + id: sequence.id, + parts: (sequence.parts || []).map((part) => + part.map(([lon, lat]) => [lon, lat]), + ), + }); + return lines; + } + return { attach, detach, @@ -543,5 +562,6 @@ export function createLineCoverage({ highlight, sequenceCount, stats, + drawnLines, }; } diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 60bae7eb3..dd9282119 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -654,3 +654,67 @@ test('the selected sequence is drawn and highlighted even past the per-tile cap' assert.ok(state.coverage.highlight, 'the selected line is highlighted'); coverage.clear(); }); + +test('drawnLines reports the lines on the globe as [lon, lat] copies, filtered like the drawing', async () => { + const { source, state, coverage, centre } = setup(); + assert.deepEqual(coverage.drawnLines(), [], 'nothing before a tile'); + const sequences = [ + { + id: 'flat-1', + parts: [ + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], + ], + capturedAt: 2_000_000_000_000, + }, + { + id: 'pano-1', + parts: [ + [ + [centre.lon, centre.lat - 0.001], + [centre.lon, centre.lat], + [centre.lon, centre.lat + 0.001], + ], + ], + capturedAt: 1_000_000_000_000, + isPano: true, + }, + ]; + state.filter = { pano: 'pano', sinceDays: 0 }; + coverage.refresh(); + source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); + await settle(); + const lines = coverage.drawnLines(); + assert.deepEqual( + lines.map((line) => line.id), + ['pano-1'], + 'only the drawn (filtered) lines', + ); + assert.equal(lines[0].parts.length, 1); + assert.equal(lines[0].parts[0].length, 3); + // Tile coordinates are quantised to the tile grid (a few metres at z14). + const [midLon, midLat] = lines[0].parts[0][1]; + assert.ok( + Math.abs(midLon - centre.lon) < 1e-4 && + Math.abs(midLat - centre.lat) < 1e-4, + `coordinates as [lon, lat]: ${midLon}, ${midLat} vs ${centre.lon}, ${centre.lat}`, + ); + assert.ok( + lines[0].parts[0][0][1] < lines[0].parts[0][2][1], + 'vertex order kept: south to north', + ); + // Copies: a caller cannot bend the drawn line. + lines[0].parts[0][1][0] = 0; + assert.notEqual(coverage.drawnLines()[0].parts[0][1][0], 0); + state.filter = { pano: 'all', sinceDays: 0 }; + coverage.rebuild(); + assert.deepEqual( + coverage + .drawnLines() + .map((line) => line.id) + .sort(), + ['flat-1', 'pano-1'], + ); +}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 4bf7f953d..e135307e7 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -40,6 +40,14 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); /** Nearest-image search radius in metres. */ export const NEAREST_RADIUS_M = 50; +/** + * Widest nearest-image radius a caller may ask for, in metres. A geocoded + * landmark sits at its centroid, often further than 50 m from the nearest + * street; providers that can search wider (Google) honour it, the rest keep + * their own cap. + */ +export const NEAREST_RADIUS_MAX_M = 250; + /** The Google 3D map stack: Street View may be shown only on it. */ export const GOOGLE_3D_MAP_STACK_ID = 'photoreal'; diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index d32ee95f6..8af24275c 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -12,6 +12,7 @@ import { GROUND_CLICK_HINT, KEY_REJECTED_MESSAGE, NEAREST_RADIUS_M, + NEAREST_RADIUS_MAX_M, NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE, PICK_PREFIX, @@ -67,6 +68,7 @@ export function createGoogleProvider({ requiresMapStack: GOOGLE_3D_MAP_STACK_ID, // Every panorama is billed to the Maps key. defaultOn: false, + billedPerImage: true, // All 360°, and no coverage for a date filter to thin. filters: [], marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), @@ -136,8 +138,12 @@ export function createGoogleProvider({ * Nearest Google-collected outdoor panorama, or null. The imagery * filters do not apply (see `filters`). */ - async nearestImage({ lat, lon }, { signal } = {}) { + async nearestImage({ lat, lon }, { signal, radiusM } = {}) { if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE); + // Wider than the shared default only on request, never past the cap. + const radius = Number.isFinite(radiusM) + ? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM)) + : NEAREST_RADIUS_M; signal?.throwIfAborted(); // Set once the caller has its answer (aborted, timed out, refused): // a lookup still waiting on Google's script then asks nothing. @@ -150,7 +156,7 @@ export function createGoogleProvider({ try { const { data } = await service.getPanorama({ location: { lat, lng: lon }, - radius: NEAREST_RADIUS_M, + radius, // Both: Google's own imagery, outdoors (sources intersect). sources: [GOOGLE, OUTDOOR], preference: lib.StreetViewPreference.NEAREST, diff --git a/src/layers/streetLevel/providers/google/policy.js b/src/layers/streetLevel/providers/google/policy.js index e0fa40cfb..d8ec0bb69 100644 --- a/src/layers/streetLevel/providers/google/policy.js +++ b/src/layers/streetLevel/providers/google/policy.js @@ -1,7 +1,7 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; import { msg } from '../../../../i18n/index.js'; -export { NEAREST_RADIUS_M } from '../../policy.js'; +export { NEAREST_RADIUS_M, NEAREST_RADIUS_MAX_M } from '../../policy.js'; export const GOOGLE_PROVIDER_ID = 'google'; export const GOOGLE_NAME = msg('Google Street View'); diff --git a/src/layers/streetLevel/providers/google/provider.test.mjs b/src/layers/streetLevel/providers/google/provider.test.mjs index 3314f0048..a78d9201f 100644 --- a/src/layers/streetLevel/providers/google/provider.test.mjs +++ b/src/layers/streetLevel/providers/google/provider.test.mjs @@ -291,3 +291,13 @@ test('the panorama a lookup found is dated without a second lookup', async () => 'no getPanorama({pano}) for a panorama the lookup already described', ); }); + +test('a wider lookup radius is honoured up to the cap', async () => { + const { instance, fake } = setup(); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 250 }); + assert.equal(fake.lookups.at(-1).radius, 250); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 9999 }); + assert.equal(fake.lookups.at(-1).radius, 250); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: NaN }); + assert.equal(fake.lookups.at(-1).radius, 50); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 6e75f967a..523f6920c 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -535,6 +535,7 @@ export function createKartaViewProvider({ loading: state.sequence.loading, error: state.sequence.error, }), + coverageLines: () => coverage.drawnLines(), /** * The nearest photo within the search radius that can be shown, or diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 928922195..901df886c 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -191,6 +191,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) { loading: state.sequence.loading, error: state.sequence.error, }), + coverageLines: () => parts.coverage.drawnLines(), viewer: photoViewer || diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index e855c9c72..805d304b9 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -526,6 +526,9 @@ export function createPanoramaxProvider({ cones.clearSelection(); drawn = null; }, + // The lines drawn now (current and stale tiles), as [lon, lat] + // parts, for diagnostics and the browser gate's line clicks. + coverageLines: () => coverage.drawnLines(), sequenceStats: () => ({ selectedId: state.sequence.selectedId, images: state.sequence.images.length, diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 628805ce1..4ad4e9655 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -1182,3 +1182,9 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a assert.equal(env.instance.sequenceStats().error, failure.message); } }); + +test('coverageLines exposes the drawn lines, none before any tile', () => { + const { instance } = setup(); + assert.equal(typeof instance.coverageLines, 'function'); + assert.deepEqual(instance.coverageLines(), []); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 5e51929a6..2c3fe42ca 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -20,6 +20,7 @@ export function filtersOf(provider) { * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. * @property {boolean} [defaultOn] Default true; the first that defaults on is lit. + * @property {boolean} [billedPerImage] Every image load costs the key's owner: a link never opens one without a click. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). @@ -48,7 +49,8 @@ export function filtersOf(provider) { * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats] - * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). + * @property {() => Array<{id: string, parts: Array>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate). + * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it. * @property {ViewerAdapter} viewer * * @typedef {object} ViewerAdapter diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index f9e5e4c88..7408b7dee 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -29,6 +29,8 @@ export function freshStreet() { externalUrl: null, loading: false, error: null, + // A lookup a link prepared but left to a click: {point, providerIds, radiusM}. + armed: null, }; } diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 16c992afd..f56d2487f 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -99,12 +99,17 @@ export function createViewerHost({ state, parts }) { } /** - * Open an image: true once its first pose is in, false if it failed or was - * overtaken. `frame: false` leaves the camera where it is. + * Open an image: true once its first pose is in, false if it failed, was + * overtaken, or `signal` fired first. An abort while the viewer mounts or + * loads closes the photo: nothing opens for a request that was withdrawn. */ - async function open(providerId, imageId, { frame = true } = {}) { + async function open( + providerId, + imageId, + { frame = true, signal = null } = {}, + ) { const entry = state.providers.get(providerId); - if (!entry || !imageId) return false; + if (!entry || !imageId || signal?.aborted) return false; if (!state.street.host) { // A nearest-image lookup may have set it; nothing is loading now. state.street.loading = false; @@ -115,6 +120,10 @@ export function createViewerHost({ state, parts }) { } const seq = ++openSeq; const current = () => seq === openSeq; + const onAbort = () => { + if (current()) close(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); // Claimed now, honoured after loading only if nothing newer took the camera. const ticket = frame ? parts.framing.begin() : null; Object.assign(state.street, { @@ -138,6 +147,7 @@ export function createViewerHost({ state, parts }) { if (current()) state.street.error = error?.message || msg('Image could not be opened'); } finally { + signal?.removeEventListener('abort', onAbort); if (current()) state.street.loading = false; notify(); } diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index fdcb74e21..c7e7d0007 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -557,6 +557,51 @@ test('an adapter that can frame but says this image has no frame offers no FIT / assert.equal(state.street.renderModes, true, 'a flat image can be framed'); }); +test('a caller that withdraws while the viewer is still mounting gets no photo', async () => { + const gate = deferred(); + const adapter = fakeAdapter({ gate }); + const { state, host } = harness(adapter); + const controller = new AbortController(); + const pending = host.open('mapillary', 'a', { signal: controller.signal }); + assert.equal(state.street.open, true, 'opening'); + controller.abort(); // the library is still loading + gate.resolve(); + assert.equal(await pending, false); + assert.deepEqual(adapter.calls.open, [], 'the image was never opened'); + assert.equal(state.street.open, false, 'the photo is closed'); + assert.equal(state.street.loading, false, 'no stuck spinner'); + assert.equal(state.street.imageId, null); +}); + +test('a caller that withdraws while the photo is loading gets it closed', async () => { + const openGate = deferred(); + const adapter = fakeAdapter({ openGate }); + const { state, host } = harness(adapter); + const controller = new AbortController(); + const pending = host.open('mapillary', 'a', { signal: controller.signal }); + await settle(); + assert.deepEqual(adapter.calls.open, ['a'], 'loading'); + controller.abort(); + openGate.resolve(); + assert.equal(await pending, false); + assert.equal(state.street.open, false, 'the withdrawn photo is closed'); + assert.equal(state.street.imageId, null); + // An already-withdrawn request asks nothing at all. + const done = new AbortController(); + done.abort(); + assert.equal( + await host.open('mapillary', 'b', { signal: done.signal }), + false, + ); + assert.deepEqual(adapter.calls.open, ['a']); + // A signal that never fires changes nothing. + assert.equal( + await host.open('mapillary', 'c', { signal: new AbortController().signal }), + true, + ); + assert.equal(state.street.imageId, 'c'); +}); + test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => { const { state, host, google } = twoProviders(); await host.open('google', 'g-1'); diff --git a/src/locations.js b/src/locations.js index d17186905..5da9432c4 100644 --- a/src/locations.js +++ b/src/locations.js @@ -380,6 +380,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { }); return { label, + latitude: lat, + longitude: lng, navigationMode: 'natural-region-swath', rangeM: swath.rangeM, }; @@ -415,6 +417,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { if (flight) { return { label, + latitude: lat, + longitude: lng, navigationMode, rangeM: null, }; @@ -468,6 +472,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { }); return { label, + latitude: lat, + longitude: lng, navigationMode: requestedRange ? 'explicit-range' : options.forceClose diff --git a/src/locations.test.mjs b/src/locations.test.mjs index 8d2a9503c..10e3b53fb 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -778,3 +778,35 @@ test('a precise search without an outline frames against the resolved ground, no const eye = target.height + flight.offset.range * Math.sin(-flight.offset.pitch); assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`); }); + +test('searchAndFlyTo reports the place it flew to, whichever framing it chose', async () => { + // A city frames its viewport. + const city = await runSearch(stubViewer(), {}); + assert.equal(city.latitude, 30.2672); + assert.equal(city.longitude, -97.7431); + // A precise place flies to a landmark. + const place = await runSearch(stubViewer(), {}, { + query: 'Texas State Capitol', + result: { + formatted_address: '1100 Congress Ave, Austin, TX', + types: ['establishment', 'point_of_interest'], + geometry: { location: { lat: 30.2747, lng: -97.7403 } }, + }, + }); + assert.equal(place.latitude, 30.2747); + assert.equal(place.longitude, -97.7403); + // A natural region frames a swath. + const region = await runSearch(stubViewer(), {}, { + query: 'Rocky Mountains', + result: { + formatted_address: 'Rocky Mountains', + types: ['natural_feature', 'establishment'], + geometry: { + location: { lat: 43.7, lng: -110.7 }, + viewport: { southwest: { lat: 35, lng: -118 }, northeast: { lat: 52, lng: -104 } }, + }, + }, + }); + assert.equal(region.latitude, 43.7); + assert.equal(region.longitude, -110.7); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 74a44e77d..552d3b417 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -25,11 +25,12 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 31-tool inventory', () => { +test('Realtime schema exposes the authoritative 32-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 31); + assert.equal(tools.length, 32); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 31, 'tool names are unique'); + assert.equal(new Set(names).size, 32, 'tool names are unique'); + assert.ok(names.includes('show_street_level')); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); assert.ok(names.includes('set_voice_inactivity_timeout')); @@ -199,6 +200,8 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'track_entity', // Point-and-ask: 'pointer' sentinels and referent arguments. 'annotate_map', + // Street-level photos: a new composite tool, not an edit to a shipped one. + 'show_street_level', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 7e2e32314..602a3b065 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -115,3 +115,17 @@ test('OPEN STREET VIEW keeps its width beside a long coverage line', () => { ); assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/); }); + +test('embed mode shows an open photo over the globe, filling the overlay', () => { + const overlay = css.match( + /body\.ui-embed \.sl-viewer-wrap-embed \{([^}]*)\}/, + ); + assert.ok(overlay, 'embed styles the viewer it moves over the globe'); + for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000']) + assert.ok(overlay[1].includes(rule), rule); + // The photo takes the overlay's height, as it does full screen. + assert.match( + css, + /body\.ui-embed \.sl-viewer-wrap-embed \.sl-viewer \{[^}]*flex: 1 1 auto/, + ); +}); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 5284e8a06..3de7902b0 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -47,10 +47,26 @@ export async function resolveViewArguments(args, { services, signal, tools }) { ? { ...args.camera } : { ...base.camera, ...args.camera }; let label = null; + // Street-level photos open at the area's centre unless a point is given; + // a new area moves a passed-through view's photos with it. + let streetLevel = + args.street_level && typeof args.street_level === 'object' + ? { ...args.street_level } + : base.street_level && typeof base.street_level === 'object' + ? args.area + ? { provider: base.street_level.provider } + : { ...base.street_level } + : null; if (args.area) { const area = await resolveArea(args.area, { services, signal }); label = area.label; const framed = cameraForArea(area); + if ( + streetLevel && + streetLevel.lat === undefined && + streetLevel.lon === undefined + ) + streetLevel = { ...streetLevel, lat: framed.lat, lon: framed.lon }; // A tilted view of an area looks at its center from behind, unless the // caller placed the camera itself. camera = @@ -84,6 +100,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) { map: args.map ?? base.map ?? null, follow, annotations: args.annotations ?? base.annotations ?? [], + street_level: streetLevel, }); } catch (error) { // A view passed through can hold anything; report it as bad arguments. @@ -113,7 +130,9 @@ export const showInGodsEyeView = defineTool({ 'it. Pass the view another answer returned, or describe one: an area ' + 'framed from above or a camera position, with chosen data layers, visual ' + 'style and map, optionally following an aircraft (or riding in its ' + - 'cockpit view) or a satellite, and with marks drawn on the map.', + 'cockpit view) or a satellite, with marks drawn on the map, or opening ' + + 'street-level photos (Google Street View, Mapillary, KartaView or ' + + 'Panoramax) at a place.', inputSchema: { type: 'object', properties: VIEW_ARGUMENTS, diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 7be532150..aac74a776 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -113,3 +113,104 @@ test('links carry a camera, style, map and an entity to follow', async () => { /Give a view, an area, or a camera with lat and lon/, ); }); + +test('links can open street-level photos at the area, on the map the source needs', async () => { + const { viewFromParams } = await import('../../view/index.js'); + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const result = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'google' }, + }); + const { view } = result.data; + assert.equal(view.map, 'photoreal'); + assert.deepEqual(view.layers, ['street-level']); + assert.deepEqual(view.street_level, { + provider: 'google', + lat: 38.5799, + lon: -121.4937, + }); + const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); + assert.equal(params.get('sl'), 'google,38.57990,-121.49370'); + assert.equal(params.get('map'), 'photoreal'); + assert.deepEqual(viewFromParams(params), view); + + const point = await catalog.call('show_in_gods_eye_view', { + view, + street_level: { provider: 'mapillary', lat: 38.58, lon: -121.49 }, + }); + assert.deepEqual(point.data.view.street_level, { + provider: 'mapillary', + lat: 38.58, + lon: -121.49, + }); + await assert.rejects( + catalog.call('show_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + map: 'osm', + street_level: { provider: 'google' }, + }), + /photoreal/, + ); +}); + +test('a new area moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const first = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'google' }, + }); + const second = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + area: { lat: 42.3314, lon: -83.0458, radius_km: 1 }, + }); + assert.deepEqual(second.data.view.street_level, { + provider: 'google', + lat: 42.3314, + lon: -83.0458, + }); + const tilted = await catalog.call('show_in_gods_eye_view', { + area: { lat: 48.8584, lon: 2.2945, radius_km: 1 }, + camera: { pitch_deg: -30 }, + street_level: { provider: 'mapillary' }, + }); + assert.notEqual( + tilted.data.view.camera.lat, + 48.8584, + 'the camera sits behind the target', + ); + assert.deepEqual(tilted.data.view.street_level, { + provider: 'mapillary', + lat: 48.8584, + lon: 2.2945, + }); + await assert.rejects( + catalog.call('show_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + follow: { kind: 'aircraft', id: 'AE1234' }, + street_level: {}, + }), + /cannot both follow/, + ); +}); + +test('a view passed through alone keeps its street-level photos', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const first = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'mapillary' }, + }); + const again = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + }); + assert.deepEqual(again.data.view.street_level, first.data.view.street_level); + assert.deepEqual(again.data.view, first.data.view); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c3ad27c14..2dd75cd3b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1318,9 +1318,12 @@ export class StyleManager extends ShellFacade { }; } if (!target.available) { + // The controller knows why (Google 3D: no Maps key; others: no ion token). return { ok: false, - error: `${target.label} requires a Cesium ion token`, + error: + target.unavailableReason || + `${target.label} requires a Cesium ion token`, activeStack: this.mapStackController.getActiveId(), }; } diff --git a/src/ui/setMapStackReason.test.mjs b/src/ui/setMapStackReason.test.mjs new file mode 100644 index 000000000..ca412116c --- /dev/null +++ b/src/ui/setMapStackReason.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; + +test('an unavailable map stack reports the controller’s own reason, not always an ion token', async () => { + const controller = { + getStacks: () => [ + { + id: 'photoreal', + label: 'Google 3D', + available: false, + unavailableReason: 'Google 3D needs GOOGLE_MAPS_API_KEY', + }, + { id: 'bing-aerial', label: 'Bing aerial', available: false }, + ], + getActiveId: () => 'osm', + }; + const shell = { mapStackController: controller }; + const photoreal = await StyleManager.prototype.setMapStack.call( + shell, + 'photoreal', + ); + assert.deepEqual(photoreal, { + ok: false, + error: 'Google 3D needs GOOGLE_MAPS_API_KEY', + activeStack: 'osm', + }); + const bing = await StyleManager.prototype.setMapStack.call( + shell, + 'bing-aerial', + ); + assert.equal(bing.error, 'Bing aerial requires a Cesium ion token'); +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index d680bc00f..001157f7c 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -11,6 +11,11 @@ const RENDER_MODE_KEY = 'gev:street-level:render-mode'; /** classes of the modes that hide every panel; an expanded photo leaves with them. */ const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; +/** True on an embedded page (`?embed=1` or the inline panel). */ +function isEmbedPage() { + return globalThis.document?.body?.classList?.contains('ui-embed') === true; +} + /** * Drives the Street Level panel body: provider chips, filters and viewer. * The panel view renders what this presents (`gev-street-level` store); the @@ -137,6 +142,15 @@ export class StreetLevelControls { }); } this.store.setExpandAvailable(Boolean(this._expandMode)); + // Embed mode: Esc closes an open photo, since the rail it would go back to + // is hidden. Full screen keeps its own Esc (above), which leaves it first. + this.listen(document, 'keydown', (event) => { + if (event.key !== 'Escape' || !this._embedHome || this.isViewerExpanded()) + return; + event.preventDefault(); + event.stopPropagation(); + this.layer.closeViewer?.(); + }); try { const stored = localStorage.getItem(RENDER_MODE_KEY); if (stored === 'fill' || stored === 'letterbox') @@ -293,6 +307,32 @@ export class StreetLevelControls { this._requestResize(); } + /** + * Embed mode hides the right rail, panel and viewer with it: an open photo + * moves to as an overlay on the globe (`sl-viewer-wrap-embed`, see + * embed.css) and returns to its place in the panel when it closes. The + * panel view keeps patching the wrap where it is: its siblings are static, + * so Vue never inserts next to it. + */ + _syncEmbedOverlay(open) { + const wrap = this._elements.viewerWrap; + if (!wrap || !document.body) return; + const lift = open === true && isEmbedPage(); + if (lift && !this._embedHome) { + this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling }; + document.body.appendChild(wrap); + wrap.classList.add('sl-viewer-wrap-embed'); + this._requestResize(); + } else if (!lift && this._embedHome) { + wrap.classList.remove('sl-viewer-wrap-embed'); + const { parent, next } = this._embedHome; + this._embedHome = null; + if (parent?.isConnected) + parent.insertBefore(wrap, next?.parentNode === parent ? next : null); + this._requestResize(); + } + } + connect() { this._unsubscribe?.(); this._unsubscribe = null; @@ -350,6 +390,9 @@ export class StreetLevelControls { const open = view.viewer.open; if (open && !this._wasOpen) this.setCollapsed(false, { persist: false }); this._wasOpen = open; + // Embed mode hides the rail the panel sits in: an open photo is shown over + // the globe instead, and goes back into the panel when it closes. + this._syncEmbedOverlay(open); } destroy() { @@ -362,6 +405,7 @@ export class StreetLevelControls { if (this._expandMode === 'popover') this._elements.viewerWrap?.removeAttribute('popover'); this._watchPanelHidingModes(false); + this._syncEmbedOverlay(false); this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 89b3c0964..9a38c0b98 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -921,3 +921,103 @@ test('a window changed elsewhere under a focused SINCE thumb leaves its readout assert.equal(since.getAttribute('aria-valuetext'), label()); controls.destroy(); })); + +/** In embed mode, the viewer moved out of the hidden rail onto the globe. */ +const overGlobe = (dom) => + dom.wrap.parentNode === dom.document.body && + dom.wrap.classList.contains('sl-viewer-wrap-embed'); + +const escape = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + +test('in embed mode an open photo leaves the hidden rail for an overlay on the globe, and goes back when it closes', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const home = dom.wrap.parentNode; + const after = dom.wrap.nextSibling; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + assert.equal(overGlobe(dom), false, 'nothing to show yet'); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), true, 'shown over the globe'); + assert.equal(dom.wrap.hidden, false, 'the panel view still renders it'); + await layer.publish(uiState({ open: true, renderMode: 'fill' })); + assert.equal(overGlobe(dom), true, 'an update keeps it there'); + assert.equal( + dom.wrap.querySelector('[aria-checked="true"]')?.textContent.trim(), + 'FILL', + 'and the panel view keeps patching it there', + ); + await layer.publish(uiState({ open: false })); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home, 'back in the panel'); + assertSameNode(dom.wrap.nextSibling, after, 'in its own place'); + assert.equal(dom.wrap.hidden, true); + controls.destroy(); + })); + +test('outside embed mode a photo stays in the panel', () => + withDom(async (dom) => { + const home = dom.wrap.parentNode; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home); + controls.destroy(); + })); + +test('in embed mode Esc closes the photo, and the next photo is shown over the globe again', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + let closed = 0; + layer.closeViewer = () => { + closed++; + layer.publish(uiState({ open: false })); + }; + await layer.publish(uiState({ open: true })); + escape(); + await nextTick(); + assert.equal(closed, 1, 'Esc closed the photo'); + assert.equal(overGlobe(dom), false); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), true, 'the next photo is shown'); + controls.destroy(); + })); + +test('in embed mode Esc in full screen leaves full screen and keeps the photo', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + let closed = 0; + layer.closeViewer = () => closed++; + await layer.publish(uiState({ open: true })); + await dom.wrap.requestFullscreen(); + await settle(); + assert.equal(controls.isViewerExpanded(), true); + escape(); + assert.equal(closed, 0, 'the photo stays open'); + controls.destroy(); + })); + +test('outside embed mode Esc does not close a photo in the panel', () => + withDom(async (dom) => { + const { layer, controls } = await stubPanel(dom, uiState({ open: true })); + let closed = 0; + layer.closeViewer = () => closed++; + escape(); + assert.equal(closed, 0); + controls.destroy(); + })); + +test('destroying the panel in embed mode puts an open photo back in the panel', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const home = dom.wrap.parentNode; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + await layer.publish(uiState({ open: true })); + controls.destroy(); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home); + })); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index 5528a76da..a376ac5da 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -13,3 +13,19 @@ body.ui-embed #world-overlay-actions, body.ui-embed #view-switcher { display: none !important; } + +/* Street-level photos: the rail that holds the panel is hidden here, so an + open photo shows over the globe (streetLevelControls moves the viewer). */ +body.ui-embed .sl-viewer-wrap-embed { + position: fixed; + inset: 12px; + z-index: 1000; +} + +body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage, +body.ui-embed .sl-viewer-wrap-embed .sl-viewer { + flex: 1 1 auto; + height: auto; + min-height: 0; + aspect-ratio: auto; +} diff --git a/src/view/index.js b/src/view/index.js index 022914277..5caecdd70 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -11,6 +11,7 @@ import { decodeLayerStateParams, encodeLayerStateParams, } from '../data/layerState.js'; +import { encodeIdSwitches } from '../data/idSwitches.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; /** Visual styles by internal name, with the name share links use. */ @@ -65,6 +66,63 @@ const FOLLOWABLE = Object.freeze({ export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE)); +/** + * Street-level imagery sources a view can open, the layer that shows them and + * the map a source needs (Google's terms allow Street View beside Google maps + * only). Literal here: this module stays portable. + */ +const STREET_LEVEL_LAYER = 'street-level'; +export const VIEW_STREET_LEVEL_PROVIDERS = Object.freeze([ + 'google', + 'mapillary', + 'kartaview', + 'panoramax', +]); +const STREET_LEVEL_MAP = Object.freeze({ google: 'photoreal' }); +const STREET_LEVEL_PARAM = 'sl'; +const EARTH_RADIUS_M = 6_371_000; + +/** Above this a camera frames a region, not a street: photos open under it. */ +const LOOK_AT_MAX_ALTITUDE_M = 20_000; +/** Flatter than this the look-at point is at the horizon: use the camera's. */ +const LOOK_AT_MIN_PITCH_DEG = -5; +/** Further ahead than this the camera frames a region: use its own point. */ +const LOOK_AT_MAX_DISTANCE_M = 10_000; + +/** + * The ground point a street-scale camera looks at: straight below it when it + * looks down, else ahead along its heading by altitude / tan(pitch). A camera + * with no altitude, above street scale, aimed at the horizon, or looking + * further than 10 km ahead gives its own point. Longitude wraps at the + * antimeridian. + */ +function lookAtPoint(camera) { + const pitch = Number(camera.pitch_deg ?? -90); + const altitude = Number(camera.altitude_m); + if ( + !(altitude > 0) || + altitude > LOOK_AT_MAX_ALTITUDE_M || + !(pitch > -89) || + pitch > LOOK_AT_MIN_PITCH_DEG + ) + return { lat: camera.lat, lon: camera.lon }; + const ground = altitude / Math.tan((-pitch * Math.PI) / 180); + if (ground > LOOK_AT_MAX_DISTANCE_M) + return { lat: camera.lat, lon: camera.lon }; + const heading = ((Number(camera.heading_deg) || 0) * Math.PI) / 180; + const dLat = + ((ground * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI); + const cosLat = Math.cos((camera.lat * Math.PI) / 180) || 1e-6; + const dLon = + ((ground * Math.sin(heading)) / (EARTH_RADIUS_M * cosLat)) * + (180 / Math.PI); + const lon = camera.lon + dLon; + return { + lat: clamp(camera.lat + dLat, -90, 90), + lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon, + }; +} + /** Annotation kinds the app draws, as in its annotate_map action. */ export const VIEW_ANNOTATION_TYPES = Object.freeze([ 'pin', @@ -162,6 +220,21 @@ export const VIEW_PROPERTIES = Object.freeze({ additionalProperties: false, }, }), + street_level: Object.freeze({ + type: 'object', + description: + 'Open street-level photos at a point: Google Street View (google; the ' + + 'map becomes Google 3D), Mapillary, KartaView or Panoramax. lat/lon ' + + 'default to the spot the camera looks at. The app flies there and ' + + 'opens the nearest photo; a plain link with google only arms it, ' + + 'leaving the billed load to a click. Not with follow.', + properties: { + provider: { type: 'string', enum: [...VIEW_STREET_LEVEL_PROVIDERS] }, + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + }, + additionalProperties: false, + }), follow: Object.freeze({ type: 'object', description: @@ -251,6 +324,7 @@ export function createView({ map = null, follow = null, annotations = [], + street_level: streetLevel = null, } = {}) { if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon)) throw new TypeError('A view needs a camera lat and lon'); @@ -264,10 +338,37 @@ export function createView({ throw new TypeError(`Cannot follow a ${follow.kind}`); if (follow?.cockpit === true && !followed.cockpit) throw new TypeError(`A ${follow.kind} has no cockpit view`); + let street = null; + if (streetLevel) { + if (follow) + throw new TypeError( + 'A view cannot both follow an entity and open street-level photos', + ); + const provider = streetLevel.provider ?? null; + if (provider !== null && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) + throw new TypeError(`Unknown street-level source: ${provider}`); + const needs = provider ? STREET_LEVEL_MAP[provider] || null : null; + if (needs && map !== null && map !== needs) + throw new TypeError( + `${provider} street-level imagery shows only on the ${needs} map`, + ); + if (needs) map = needs; + // The point defaults to where the camera looks: a view "of X" opens + // photos at X, also when the camera sits behind it. + const seen = lookAtPoint(camera); + const lat = latitude(streetLevel.lat) ?? seen.lat; + const lon = longitude(streetLevel.lon) ?? seen.lon; + street = Object.freeze({ + ...(provider ? { provider } : {}), + lat: round(clamp(lat, -90, 90), 5), + lon: round(clamp(lon, -180, 180), 5), + }); + } const enabled = new Set( layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)), ); if (followed) enabled.add(followed.layer); + if (street) enabled.add(STREET_LEVEL_LAYER); return Object.freeze({ camera: Object.freeze({ lat: round(clamp(camera.lat, -90, 90), 4), @@ -296,6 +397,7 @@ export function createView({ .map(annotationOf) .filter(Boolean), ), + street_level: street, }); } @@ -321,8 +423,29 @@ export function viewToParams(view) { ...state.options[followed.owner], [followed.key]: view.follow.id, }; + // The restored layer lights the source the photos come from. + const provider = view.street_level?.provider; + if (provider) + state.options[STREET_LEVEL_LAYER] = { + ...state.options[STREET_LEVEL_LAYER], + mapillary: provider === 'mapillary', + providerSwitches: encodeIdSwitches( + VIEW_STREET_LEVEL_PROVIDERS.filter((id) => id !== 'mapillary').map( + (id) => [id, id === provider], + ), + ), + }; encodeLayerStateParams(params, state); } + if (view.street_level) + params.set( + STREET_LEVEL_PARAM, + [ + view.street_level.provider ?? '', + view.street_level.lat.toFixed(5), + view.street_level.lon.toFixed(5), + ].join(','), + ); if (view.annotations.length) { const encoded = JSON.stringify(view.annotations); if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS) @@ -345,6 +468,40 @@ export function annotationsFromParams(params) { } } +/** + * The street-level photos a share link opens (`sl=,,`, the + * source optional), or null. + */ +export function streetLevelFromParams(params) { + const raw = params.get(STREET_LEVEL_PARAM); + if (!raw) return null; + // Three fields, plain decimal degrees: that alone bounds the value. + const parts = raw.split(','); + if (parts.length !== 3) return null; + const [provider, lat, lon] = parts; + if (provider && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) return null; + // Plain decimal degrees only: no exponents, hex or blanks. + const DECIMAL = /^-?\d{1,3}(\.\d{1,8})?$/; + if (!DECIMAL.test(lat) || !DECIMAL.test(lon)) return null; + const point = { lat: latitude(Number(lat)), lon: longitude(Number(lon)) }; + if (point.lat === undefined || point.lon === undefined) return null; + return { ...(provider ? { provider } : {}), ...point }; +} + +/** The entity a decoded layer state follows, or null. */ +function followFromState(state) { + for (const [kind, followed] of Object.entries(FOLLOWABLE)) { + const id = state?.options?.[followed.owner]?.[followed.key]; + if (id !== null && id !== undefined) return { kind, id: String(id) }; + } + return null; +} + +/** The entity a share link follows, or null. */ +export function followFromParams(params) { + return followFromState(decodeLayerStateParams(params)); +} + /** The view a set of share-link hash parameters restores, or null. */ export function viewFromParams(params) { const lat = Number.parseFloat(params.get('lat')); @@ -355,13 +512,12 @@ export function viewFromParams(params) { return Number.isFinite(value) ? value : undefined; }; const state = decodeLayerStateParams(params); - let follow = null; - for (const [kind, followed] of Object.entries(FOLLOWABLE)) { - const id = state?.options?.[followed.owner]?.[followed.key]; - if (id !== null && id !== undefined && !follow) - follow = { kind, id: String(id) }; - } + const follow = followFromState(state); const map = params.get('map'); + // A link is untrusted input: its photos win over a map their source cannot + // show on, and a followed entity keeps the camera. + const street = follow ? null : streetLevelFromParams(params); + const needs = street?.provider ? STREET_LEVEL_MAP[street.provider] : null; return createView({ camera: { lat, @@ -376,9 +532,10 @@ export function viewFromParams(params) { : Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) ? STYLES_BY_URL_NAME[params.get('style')] : null, - map: VIEW_MAPS.includes(map) ? map : null, + map: needs ? needs : VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), + street_level: street, }); } diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 66630e6a0..1389b058b 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -127,6 +127,379 @@ test('annotations travel in the link, keeping only what the app draws', async () assert.equal(viewToParams(many).has('an'), false); }); +test('street-level photos travel in the link, light their source and bring their map', async () => { + const { streetLevelFromParams, VIEW_STREET_LEVEL_PROVIDERS } = + await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + assert.deepEqual(VIEW_STREET_LEVEL_PROVIDERS, [ + 'google', + 'mapillary', + 'kartaview', + 'panoramax', + ]); + const view = createView({ + camera: { lat: 38.5799, lon: -121.4937, altitude_m: 400 }, + street_level: { provider: 'google' }, + }); + assert.equal(view.map, 'photoreal', 'Street View brings Google 3D'); + assert.deepEqual(view.layers, ['street-level']); + assert.deepEqual(view.street_level, { + provider: 'google', + lat: 38.5799, + lon: -121.4937, + }); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'google,38.57990,-121.49370'); + assert.deepEqual(viewFromParams(params), view); + const state = decodeLayerStateParams(params); + assert.deepEqual(state.enabledLayerIds, ['street-level']); + assert.equal(state.options['street-level'].mapillary, false); + assert.equal( + state.options['street-level'].providerSwitches, + 'google-1*kartaview-0*panoramax-0', + 'Street View on, KartaView off', + ); + + const mapillary = createView({ + camera: { lat: 1, lon: 2 }, + map: 'osm', + street_level: { provider: 'mapillary', lat: 1.00001, lon: 2.00001 }, + }); + assert.equal(mapillary.map, 'osm', 'Mapillary shows on any map'); + assert.deepEqual(viewFromParams(viewToParams(mapillary)), mapillary); + const lit = decodeLayerStateParams(viewToParams(mapillary)).options[ + 'street-level' + ]; + assert.equal(lit.mapillary, true); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-0*panoramax-0', + 'a Mapillary link lights Mapillary only', + ); + const anySource = createView({ + camera: { lat: 1, lon: 2 }, + street_level: {}, + }); + assert.deepEqual(anySource.street_level, { lat: 1, lon: 2 }); + assert.equal(viewToParams(anySource).get('sl'), ',1.00000,2.00000'); + assert.deepEqual(viewFromParams(viewToParams(anySource)), anySource); + + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + map: 'osm', + street_level: { provider: 'google' }, + }), + /photoreal/, + ); + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'openstreetcam' }, + }), + /Unknown street-level source/, + ); + assert.equal( + streetLevelFromParams(new URLSearchParams('sl=google,x,y')), + null, + ); + assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null); + assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null); + assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null); +}); + +test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => { + const { streetLevelFromParams } = await import('./index.js'); + // Looking north from 1,000 m at 45°: the ground point is ~1 km north. + const tilted = createView({ + camera: { + lat: 48.8494, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 0, + pitch_deg: -45, + }, + street_level: { provider: 'google' }, + }); + assert.ok( + tilted.street_level.lat > 48.858 && tilted.street_level.lat < 48.859, + String(tilted.street_level.lat), + ); + assert.equal(tilted.street_level.lon, 2.2945); + const down = createView({ + camera: { lat: 1, lon: 2, altitude_m: 1000 }, + street_level: {}, + }); + assert.deepEqual(down.street_level, { lat: 1, lon: 2 }); + // A hand-edited link: the photos win over the map. + const link = viewFromParams( + new URLSearchParams('v=2&lat=1&lon=2&map=osm&sl=google,1,2'), + ); + assert.equal(link.map, 'photoreal'); + assert.deepEqual(link.street_level, { provider: 'google', lat: 1, lon: 2 }); + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'aircraft', id: 'a1' }, + street_level: {}, + }), + /cannot both follow/, + ); + // A followed link drops its photos rather than fight over the camera. + const followed = viewFromParams( + viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ), + ); + assert.equal(followed.street_level, null); + // Strict parsing: three fields, real numbers, in range, bounded length. + for (const bad of [ + 'google,1', + 'google,1,2,3', + 'google,1abc,2', + 'google,91,0', + 'google,,2', + 'google,1,2'.padEnd(65, '0'), + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); +}); + +test('the look-at default is street-scale only, follows the heading, and wraps at the antimeridian', async () => { + const { followFromParams, streetLevelFromParams } = + await import('./index.js'); + const at = (camera) => + createView({ camera, street_level: { provider: 'mapillary' } }) + .street_level; + // Looking east from 1,000 m at 45° in Paris: ~1 km east is ~0.0137° of longitude. + const east = at({ + lat: 48.85, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 90, + pitch_deg: -45, + }); + assert.ok(Math.abs(east.lon - (2.2945 + 0.01365)) < 0.0005, String(east.lon)); + assert.ok(Math.abs(east.lat - 48.85) < 0.0002, String(east.lat)); + // Looking west from the same place lands west. + assert.ok( + at({ + lat: 48.85, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 270, + pitch_deg: -45, + }).lon < 2.2945, + ); + // At the horizon, with no altitude, or above street scale: the camera's own point. + assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 1000, pitch_deg: 0 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + assert.deepEqual(at({ lat: 1, lon: 2, pitch_deg: -30 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 50_000, pitch_deg: -30 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + // Across the antimeridian the longitude wraps instead of clamping. + const wrapped = at({ + lat: 0, + lon: 179.999, + altitude_m: 1000, + heading_deg: 90, + pitch_deg: -45, + }); + assert.ok(wrapped.lon < -179.9, String(wrapped.lon)); + + // A followed link keeps its camera: the photos are dropped even when both are in the hash. + const followed = viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ); + followed.set('sl', 'google,1.00000,2.00000'); + assert.deepEqual(followFromParams(followed), { + kind: 'satellite', + id: '25544', + }); + assert.equal(viewFromParams(followed).street_level, null); + assert.equal(followFromParams(new URLSearchParams('v=2&lat=1&lon=2')), null); + + // Strict decimal degrees: no exponents, hex, blanks or over-long values. + for (const bad of [ + 'google,1e1,2', + 'google,0x10,2', + 'google, 1,2', + 'google,1.,2', + `google,${'1.0000000'.padEnd(60, '0')},2`, + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: ',-1.5,2' })), + { lat: -1.5, lon: 2 }, + ); +}); + +test('the look-at thresholds are pinned: 20 km up, -5° of pitch, 10 km ahead', () => { + const at = (camera) => createView({ camera, street_level: {} }).street_level; + const own = { lat: 1, lon: 2 }; + const moved = (point) => point.lat !== own.lat || point.lon !== own.lon; + // Altitude ceiling, at a steep pitch that keeps the distance under 10 km. + assert.ok( + moved(at({ ...own, altitude_m: 19_999, pitch_deg: -70 })), + '19,999 m applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 20_001, pitch_deg: -70 })), + '20,001 m does not', + ); + // Pitch cutoff, at 100 m so the distance cap cannot decide it (1.4 km ahead). + assert.ok( + moved(at({ ...own, altitude_m: 100, pitch_deg: -6 })), + '-6° applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 100, pitch_deg: -4 })), + '-4° does not', + ); + // Distance cap, pinned either side of 10 km at 1,000 m: -5.8° looks 9.84 km + // ahead, -5.6° 10.2 km. + assert.ok( + moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.8 })), + '9.84 km ahead applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.6 })), + '10.2 km ahead does not', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 10_000, pitch_deg: -30 })), + '17 km ahead does not', + ); + assert.ok( + moved(at({ ...own, altitude_m: 5_000, pitch_deg: -30 })), + '8.7 km ahead applies', + ); + // Near-vertical stays put (no tan blow-up). + assert.ok(!moved(at({ ...own, altitude_m: 1000, pitch_deg: -89.5 }))); + // Western wrap and the pole clamp. + const west = at({ + lat: 0, + lon: -179.999, + altitude_m: 1000, + heading_deg: 270, + pitch_deg: -45, + }); + assert.ok(west.lon > 179.9, String(west.lon)); + const pole = at({ + lat: 89.9995, + lon: 0, + altitude_m: 1000, + heading_deg: 0, + pitch_deg: -45, + }); + assert.ok(pole.lat <= 90, String(pole.lat)); +}); + +test('sl rejects a bad longitude slot and over-precise values, and no length bound hides the regex', async () => { + const { streetLevelFromParams } = await import('./index.js'); + for (const bad of [ + 'google,1,1e1', + 'google,1,0x10', + 'google,1, 2', + 'google,1,2.', + 'google,1.123456789,2', + 'google,1,2.123456789', + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); + assert.deepEqual( + streetLevelFromParams( + new URLSearchParams({ sl: 'google,-89.12345678,179.12345678' }), + ), + { provider: 'google', lat: -89.12345678, lon: 179.12345678 }, + ); +}); + +test('KartaView photos travel in a link and light KartaView alone, on any map', async () => { + const { streetLevelFromParams } = await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + const view = createView({ + camera: { lat: 52.52, lon: 13.405, altitude_m: 400 }, + map: 'osm', + street_level: { provider: 'kartaview' }, + }); + assert.equal(view.map, 'osm', 'KartaView needs no particular map'); + assert.deepEqual(view.layers, ['street-level']); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'kartaview,52.52000,13.40500'); + assert.deepEqual(viewFromParams(params), view); + const lit = decodeLayerStateParams(params).options['street-level']; + assert.equal(lit.mapillary, false); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-1*panoramax-0', + 'KartaView on, the others off', + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: 'kartaview,1,2' })), + { + provider: 'kartaview', + lat: 1, + lon: 2, + }, + ); +}); + +test('Panoramax photos travel in a link and light Panoramax alone, on any map', async () => { + const { streetLevelFromParams } = await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + const view = createView({ + camera: { lat: 48.8874, lon: 2.3495, altitude_m: 400 }, + map: 'osm', + street_level: { provider: 'panoramax' }, + }); + assert.equal(view.map, 'osm', 'Panoramax needs no particular map'); + assert.deepEqual(view.layers, ['street-level']); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'panoramax,48.88740,2.34950'); + assert.deepEqual(viewFromParams(params), view); + const lit = decodeLayerStateParams(params).options['street-level']; + assert.equal(lit.mapillary, false); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-0*panoramax-1', + 'Panoramax on, the others off', + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: 'panoramax,1,2' })), + { provider: 'panoramax', lat: 1, lon: 2 }, + ); +}); + test('a view naming the retired Snow style opens on Normal', () => { const camera = { lat: 1, lon: 2 }; assert.equal(createView({ camera, style: 'snow' }).style, 'normal'); diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 9ec47028c..3e19d25a0 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -103,6 +103,36 @@ const schemas = [ required: ['layerId'], }, }, + { + name: 'show_street_level', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + maxLength: 160, + }, + latitude: { + type: 'number', + minimum: -90, + maximum: 90, + }, + longitude: { + type: 'number', + minimum: -180, + maximum: 180, + }, + provider: { + type: 'string', + enum: ['google', 'mapillary', 'kartaview', 'panoramax'], + }, + referent: { + type: 'integer', + }, + }, + }, + }, { name: 'adjust_camera_zoom', parameters: { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d15a7c25e..9bf2caaaa 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,21 +38,38 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. - // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. - // Air quality (ECCC AQHI) extends the manifest. - // Globe rotation adds one motion; visual keyhole fit and explicit - // reset guidance/defaults update only move_camera and mode descriptions. - // S2 GhostMaps extends the generated layer enums. - // The Utilities panel (Directions, Draw, Import) adds one panel target. - // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance - // is consolidated without changing either feature's arguments. - // Snow leaves the set_visual_style enum. - // The Operator HUD layout leaves set_hud's layout enum. - // The layer alias hint leaves out aliases the layer id already spells. + // Street Level, GNSS and air quality (ECCC AQHI) extend the manifest; + // tracked and analyst aircraft describe broadcasts; globe rotation adds + // one motion; S2 GhostMaps, the Utilities panel target and the voice panel + // (named targets, timeout) follow; Snow leaves the set_visual_style enum, the Operator HUD layout + // leaves set_hud's layout enum and the layer alias hint leaves out aliases + // the layer id already spells; show_street_level (map, source, photo) is + // the only change on top of main. + SHOW_STREET_LEVEL_PAYLOAD_DIGEST, + ); + // Every shipped tool is byte-identical to the previous release: the new + // tool is the whole difference, so a later edit cannot hide in a re-pin. + const shipped = createHash('sha256') + .update( + JSON.stringify( + stable( + GEV_REALTIME_TOOLS.filter( + (tool) => + !['set_cyber_sonar', 'show_street_level'].includes(tool.name), + ), + ), + ), + ) + .digest('hex'); + assert.equal( + shipped, 'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5', ); }); +const SHOW_STREET_LEVEL_PAYLOAD_DIGEST = + '2198c1f95cfbc19e89ba8814ad0fd8303a74a7b254703f2e7a4893f5798d9aed'; + test('descriptions customize wording without changing immutable shared arguments', () => { const descriptions = { fly_to_location: { @@ -112,6 +129,7 @@ test('all legacy action arguments are byte-identical after removing the delibera 'next_satellite_pass', 'set_cyber_sonar', 'set_voice_inactivity_timeout', + 'show_street_level', ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by diff --git a/src/voice/deixis.js b/src/voice/deixis.js index 25d7939eb..0c21f297e 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -330,6 +330,7 @@ function resolveCropPixels(args, { imageFrame, cameraKey }) { const RESOLVERS = Object.freeze({ track_entity: resolveTrack, fly_to_location: resolveFly, + show_street_level: resolveFly, get_entity_context: resolveContext, analyst_query: resolveAnalyst, annotate_map: resolveAnnotate, diff --git a/src/voice/deixis.test.mjs b/src/voice/deixis.test.mjs index bd2e66f1b..cd7f7374d 100644 --- a/src/voice/deixis.test.mjs +++ b/src/voice/deixis.test.mjs @@ -180,3 +180,10 @@ test('numbered custom-labeled pins retain their resolved coordinates', () => { assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }).args, { latitude: 30.27, longitude: -97.74 }); }); + +test('show_street_level: "street view of this" opens photos at the pointed ground point', () => { + const out = resolveDeicticArgs('show_street_level', { query: 'pointer', provider: 'google' }, { pointer: ground }); + assert.deepEqual(out.args, { provider: 'google', latitude: 30.27, longitude: -97.74 }); + assert.equal(out.used.source, 'pointer'); + assert.deepEqual(resolveDeicticArgs('show_street_level', { query: 'Eiffel Tower' }, { pointer: ground }).args, { query: 'Eiffel Tower' }); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 70636dd4e..e2c4cf964 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -34,6 +34,10 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; +import { + NEAREST_RADIUS_MAX_M as STREET_LEVEL_WIDE_RADIUS_M, + STREET_LEVEL_LAYER_ID, +} from '../layers/streetLevel/policy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -731,6 +735,18 @@ export function createGevActionRunner({ }; } + if (name === 'show_street_level') { + return showStreetLevel(args, { + dataManager, + styleManager, + placeSearch, + runGevAction, + runOptions, + current, + reportProgress, + }); + } + if (name === 'set_visual_style') { const style = normalizeStyle(args.style); if (!style) @@ -2582,6 +2598,29 @@ function stopAllTracking(viewer, dataManager) { return { ok: true, action: 'stop_tracking', released }; } +/** + * The imagery filter the lit source honours when it is narrower than the + * default (360° or flat only, or SINCE), or null. + * @returns {{ pano?: string, sinceDays?: number, label: string } | null} + */ +export function activeImageryFilter(ui) { + const filter = ui?.filter || {}; + const controls = ui?.filterControls || {}; + const out = {}; + const names = []; + if (controls.pano && (filter.pano === 'pano' || filter.pano === 'flat')) { + out.pano = filter.pano; + names.push(filter.pano === 'pano' ? '360°' : 'flat-photo'); + } + if (controls.since && Number(filter.sinceDays) > 0) { + out.sinceDays = Number(filter.sinceDays); + names.push('SINCE'); + } + if (!names.length) return null; + out.label = `${names.join(' and ')} filter${names.length > 1 ? 's' : ''}`; + return out; +} + /** * Frames entities near the current view target with a cinematic pull-back: * oblique high pitch for aircraft/ships, shallow wide pitch for satellites. @@ -2589,6 +2628,7 @@ function stopAllTracking(viewer, dataManager) { * detection so the framed entities are labeled, and reports * detectionEnabled so the voice agent can mention labels are on. */ + async function frameOverhead(viewer, dataManager, styleManager, args = {}) { const targetRaw = String(args.target || 'flights').toLowerCase(); const layerId = @@ -3245,6 +3285,311 @@ function normalizeStyle(value) { return null; } +/** + * "Show me on Street View / on Mapillary": fly to the place, make sure + * the imagery source's map is up, light that source in Street Level, turn the + * layer on and open the nearest image. One source at a time, as the panel + * does; the choice persists like a chip click. The key is checked before + * anything changes, and each stage reports its own failure so speech can say + * which part did not happen. With no place at all, the view centre is used. + * `autoOpen: false` (links, not the model) stops short of loading a photo + * from a source that bills every load: the panel is armed and the OPEN + * button is one click away. + */ +export async function showStreetLevel( + args, + { + dataManager, + styleManager, + placeSearch, + runGevAction, + runOptions = {}, + current = () => true, + reportProgress = () => {}, + }, +) { + const action = 'show_street_level'; + let provider = null; + let providerName = null; + let label = null; + const fail = (stage, error, extra = {}) => ({ + ok: false, + action, + stage, + cancelled: !current(), + error, + ...(provider ? { provider } : {}), + ...(providerName ? { providerName } : {}), + ...(label ? { label } : {}), + ...extra, + }); + const query = String(args.query || '').trim(); + const latitude = Number(args.latitude); + const longitude = Number(args.longitude); + const hasCoordinates = + args.latitude != null && + args.longitude != null && + Number.isFinite(latitude) && + Number.isFinite(longitude); + // A referent that is a layer entity (deixis): located when the flight starts. + const entity = + args.entity && typeof args.entity === 'object' && args.entity.id + ? args.entity + : null; + const layerModule = + dataManager?.layers?.get?.(STREET_LEVEL_LAYER_ID)?.module || null; + if (!layerModule || typeof layerModule.openNearest !== 'function') + return fail('provider', 'Street Level is unavailable'); + const providerIds = Array.isArray(layerModule.providerIds) + ? layerModule.providerIds + : []; + + // The source: asked for, else the one lit now, else the first registered. + const requested = String(args.provider || '') + .trim() + .toLowerCase(); + if (requested) { + if (!providerIds.includes(requested)) + return fail( + 'provider', + `${requested} is not a street-level imagery source here`, + ); + provider = requested; + } else { + const params = dataManager.getLayerParams?.(STREET_LEVEL_LAYER_ID) || {}; + provider = + providerIds.find((id) => params[id] === true) || providerIds[0] || null; + } + if (!provider) + return fail('provider', 'No street-level imagery source is configured'); + const status = layerModule.providerStatus?.(provider) || null; + providerName = status?.name || provider; + + // The key first, on any map: a chip without a key explains it and switches + // nothing, and so does this. + if (status && (status.keyRequired === true || status.configured === false)) + return fail('key', status.error || `${providerName} needs its key`, { + keyRejected: status.keyRejected === true, + keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [], + }); + + // Then the place: a name that geocodes to nothing changes nothing else. + label = + query || + (entity ? String(entity.label || entity.id) : '') || + (hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') || + 'the view centre'; + const atViewCentre = !query && !hasCoordinates && !entity; + let point = null; + let location = null; + if (args.fly === false || atViewCentre) { + if (hasCoordinates) point = { lat: latitude, lon: longitude }; + else if (entity) { + // Where it is now, as the flying path does: the coordinates a list + // saved go stale as soon as the item moves or unloads. + const located = locateLayerEntity(dataManager, entity); + if (!located) return fail('location', `${label} is no longer loaded`); + point = { lat: located.lat, lon: located.lon }; + } else if (query) { + reportProgress('search', query); + const outcome = await placeSearch.geocode(query, { + signal: runOptions.signal, + }); + const place = outcome?.place; + if (!current()) + return fail('location', 'Street-level request was cancelled'); + if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng)) + return fail('location', `Could not find ${query}`); + point = { lat: place.lat, lon: place.lng }; + label = place.label || query; + } + // No place at all: the layer looks under the view centre. + } else { + location = await runGevAction( + 'fly_to_location', + { + ...(entity + ? { entity } + : hasCoordinates + ? { latitude, longitude } + : { query }), + viewMode: 'close', + waitForArrival: true, + }, + runOptions, + ); + if (location?.ok !== true || !current()) + return fail( + 'location', + location?.error || `Could not arrive at ${location?.label || label}`, + { cancelled: !current() || Boolean(location?.cancelled), location }, + ); + // An item keeps its name: the flight to it reports only coordinates. + label = entity ? label : location.label || label; + if ( + !Number.isFinite(location.latitude) || + !Number.isFinite(location.longitude) + ) + return fail( + 'location', + `${label} has no position to look for imagery at`, + { location }, + ); + point = { lat: location.latitude, lon: location.longitude }; + } + + // The source's map, when it needs one (Street View: Google 3D only). + const requiredStack = layerModule.requiredMapStack?.(provider) || null; + const activeStack = styleManager?.mapStackController?.getActiveId?.() ?? null; + if (requiredStack && activeStack !== requiredStack) { + reportProgress('map', requiredStack); + const map = await runGevAction( + 'set_map_stack', + { stack: requiredStack }, + runOptions, + ); + if (map?.ok !== true || !current()) + return fail( + 'map', + map?.error || + `${providerName} needs the ${requiredStack} map, which is not available`, + { map, location }, + ); + } + + // Light the source the way a chip click does, so the choice is kept. + const switches = Object.fromEntries( + providerIds.map((id) => [id, id === provider]), + ); + let accepted = true; + if (typeof dataManager.setLayerParams === 'function') + accepted = + dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, { + origin: 'voice', + }) !== false; + else layerModule.setProviderEnabled?.(provider, true); + if (!accepted) + return fail('provider', `${providerName} could not be switched on`, { + location, + }); + let layer = null; + if (!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID)) { + layer = await runGevAction( + 'set_layer_visibility', + { layerId: STREET_LEVEL_LAYER_ID, enabled: true }, + runOptions, + ); + if (layer?.ok !== true || !current()) + return fail( + 'layer', + layer?.error || 'Street Level could not be enabled', + { cancelled: !current() || Boolean(layer?.cancelled), layer, location }, + ); + } + + // Listed now that its map is up, and lit. + const ui = layerModule.getUIState?.() || {}; + const snapshot = + (ui.providers || []).find((entry) => entry.id === provider) || null; + if (!snapshot) + return fail( + 'provider', + ui.coverage?.hint || `${providerName} is not available on this map`, + { location }, + ); + if (snapshot.on !== true) + return fail('provider', `${providerName} could not be switched on`, { + location, + }); + + // A billed source never loads from a link without a click: everything is + // ready and OPEN is one press away at the view centre. + if (args.autoOpen === false && status?.billed === true) { + // The next OPEN press loads exactly what the link asked for. + layerModule.armNearest?.(point, { + providerIds: [provider], + ...(snapshot.groundClick === true + ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } + : {}), + }); + return { + ok: true, + action, + provider, + providerName, + label, + armed: true, + flew: location !== null, + ...(location ? { location: location.label } : {}), + }; + } + + // A source with no coverage to click (Street View) is searched wider: a + // geocoded landmark is rarely within 50 m of a street. A newer turn + // retires the lookup: nothing opens after the user moved on. + reportProgress('imagery', label); + const opened = await layerModule.openNearest(point, { + providerIds: [provider], + frame: false, + ...(runOptions.signal ? { signal: runOptions.signal } : {}), + ...(snapshot.groundClick === true + ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } + : {}), + }); + const street = layerModule.getUIState?.()?.street || {}; + // A cancelled request opens nothing (the lookup and the viewer both honour + // the turn's signal); a photo that did open is reported as open. + if (opened !== true && !current()) + return fail('imagery', 'Street-level request was cancelled', { + cancelled: true, + location, + }); + if (opened !== true) { + // Why nothing opened: the key, a change under the lookup, or no imagery. + const after = layerModule.providerStatus?.(provider) || status || {}; + if (after.keyRequired === true || after.keyRejected === true) + return fail( + 'key', + street.error || after.error || `${providerName} needs its key`, + { keyRejected: after.keyRejected === true, location }, + ); + if ( + !dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID) || + after.on === false || + after.available === false + ) + return fail( + 'imagery', + street.error || 'Street Level changed while it looked for imagery', + { cancelled: true, location }, + ); + // An imagery filter the source honours can hide every photo here; say + // so, or the answer reads as "no coverage" when one switch would do. + const filter = activeImageryFilter(layerModule.getUIState?.()); + const reason = street.error || `No ${providerName} imagery near ${label}`; + return fail( + 'imagery', + filter ? `${reason} with the ${filter.label} on` : reason, + { location, ...(filter ? { filter } : {}) }, + ); + } + return { + ok: true, + action, + provider, + providerName, + label, + imageId: street.imageId ?? null, + title: street.title ?? null, + capturedAt: street.capturedAt ?? null, + capturedAtPrecision: street.capturedAtPrecision ?? null, + position: street.position ?? null, + externalUrl: street.externalUrl ?? null, + flew: location !== null, + ...(location ? { location: location.label } : {}), + }; +} + async function flyToRequestedLocation( viewer, args, @@ -3309,11 +3654,25 @@ async function flyToRequestedLocation( ); if (result === false) return cancelled(CITY_POIS[locationId]?.name || locationId); + // Where the camera went: the first POI for a close view, else the middle + // of the city's overview bounds. + const city = CITY_POIS[locationId]; + const overview = + !(rangeM || args.viewMode === 'close') && Boolean(city?.viewBounds); + const centre = overview + ? { + lat: + (city.viewBounds.southwest.lat + city.viewBounds.northeast.lat) / 2, + lon: + (city.viewBounds.southwest.lng + city.viewBounds.northeast.lng) / 2, + } + : city?.pois?.[0] || null; const response = { ok: Boolean(result), action: 'fly_to_location', locationId, label: CITY_POIS[locationId]?.name || locationId, + ...(centre ? { latitude: centre.lat, longitude: centre.lon } : {}), rangeM: result?.range ? Math.round(result.range) : rangeM || null, navigationMode: rangeM ? 'explicit-range' @@ -3374,6 +3733,7 @@ async function flyToRequestedLocation( action: 'fly_to_location', query, label: poi?.name || query, + ...(poi ? { latitude: poi.lat, longitude: poi.lon } : {}), navigationMode: rangeM ? 'preset-poi-range' : 'preset-poi', rangeM: result?.range ? Math.round(result.range) : rangeM || null, }; @@ -3404,6 +3764,10 @@ async function flyToRequestedLocation( action: 'fly_to_location', query, label: destination?.label || query, + ...(Number.isFinite(destination?.latitude) && + Number.isFinite(destination?.longitude) + ? { latitude: destination.latitude, longitude: destination.longitude } + : {}), navigationMode: destination?.navigationMode || null, rangeM: destination?.rangeM || rangeM || null, }; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f11abc5ae..ec0c76e5d 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -20,6 +20,7 @@ import { normalizeStackId, } from './gevActions.js'; import { MAP_STACKS } from '../mapStackController.js'; +import { KEY_REJECTED_MESSAGE } from '../layers/streetLevel/providers/google/policy.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { presentResult } from './resultDisplay.js'; import { attachVoiceResult } from './speech.js'; @@ -3987,6 +3988,538 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); +/** A Street Level layer stub, a map stack and a data manager for show_street_level. */ +function createStreetLevelHarness({ + activeStack = 'osm', + lit = { mapillary: true, google: false }, + enabled = false, + keyRequired = false, + configured = !keyRequired, + nearest = 'pano-a', + paramsAccepted = true, + enableResult = true, + listGoogle = true, + duringLookup = null, + keyRejectedAtStart = false, + lightFails = false, +} = {}) { + const order = []; + const { viewer, styleManager } = createVoiceNavigationHarness(); + let completeFlight = null; + viewer.camera.lookAt = () => {}; + viewer.camera.flyToBoundingSphere = (_sphere, options) => { + order.push('fly'); + completeFlight = options.complete; + }; + styleManager.runImmediateLocationNavigation = (navigate) => + styleManager.runImmediateNavigation('location', navigate); + let stack = activeStack; + styleManager.mapStackController = { getActiveId: () => stack }; + styleManager.setMapStack = async (id) => { + order.push(`map:${id}`); + stack = id; + return { ok: true, activeStack: id, error: null }; + }; + let params = { ...lit }; + let keyRejected = keyRejectedAtStart; + const street = { imageId: null, error: null }; + const names = { google: 'Google Street View', mapillary: 'Mapillary' }; + const layer = { + providerIds: ['mapillary', 'google'], + requiredMapStack: (id) => (id === 'google' ? 'photoreal' : null), + providerStatus: (id) => + names[id] + ? { + id, + name: names[id], + on: params[id] === true, + configured, + keyRequired: keyRequired || keyRejected, + keyRejected, + error: keyRejected ? KEY_REJECTED_MESSAGE : keyRequired ? `${names[id]} needs its key` : null, + groundClick: id === 'google', + billed: id === 'google', + available: id !== 'google' || stack === 'photoreal', + } + : null, + getUIState: () => ({ + providers: ['mapillary', 'google'] + .filter((id) => id !== 'google' || (stack === 'photoreal' && listGoogle)) + .map((id) => ({ + id, + name: id === 'google' ? 'Google Street View' : 'Mapillary', + on: params[id] === true, + configured: !keyRequired, + keyRequired, + groundClick: id === 'google', + hint: keyRequired ? `${id} needs its key` : '', + error: null, + })), + keyAlternatives: [], + coverage: { hint: 'not on this map' }, + street: { ...street }, + }), + armNearest(point, options) { + order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`); + }, + async openNearest(point, options) { + order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`); + if (options.signal?.aborted) return false; + if (duringLookup) { + const effect = duringLookup(); + if (effect === 'key-rejected') { + keyRejected = true; + street.error = KEY_REJECTED_MESSAGE; + return false; + } + if (effect === 'layer-off') { + enabled = false; + return false; + } + } + if (!nearest) { + street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`; + return false; + } + street.imageId = nearest; + street.title = '10th St, Sacramento'; + street.position = point; + return true; + }, + }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => enabled, + getLayerParams: () => ({ ...params }), + setLayerParams(id, next, { origin }) { + order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`); + if (paramsAccepted === false) return false; + if (!lightFails) params = { ...params, ...next }; + return true; + }, + async setEnabled(id) { + order.push(`enable:${id}`); + if (enableResult === false) return false; + enabled = true; + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager }; +} + +/** A loaded CCTV camera the entity branch can locate. */ +function withCamera(harness) { + harness.dataManager.layers.set('cctv', { + module: { + findByQuery: (id) => (id === 'cam-9' ? { latitude: 38.58, longitude: -121.5 } : null), + }, + }); + const wasEnabled = harness.dataManager.isEnabled; + harness.dataManager.isEnabled = (id) => id === 'cctv' || wasEnabled(id); + return harness; +} + +test('show_street_level flies first, then switches the map, lights the source, turns the layer on and opens the nearest photo', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, finishFlight } = createStreetLevelHarness(); + const pending = runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['fly'], 'nothing changes until the place is reached'); + finishFlight(); + const result = await pending; + assert.equal(result.ok, true, result.error); + assert.deepEqual(order, [ + 'fly', + 'map:photoreal', + 'params:google:voice', + 'enable:street-level', + 'open:google:250:false', + ]); + assert.equal(result.provider, 'google'); + assert.equal(result.providerName, 'Google Street View'); + assert.equal(result.imageId, 'pano-a'); + assert.equal(result.title, '10th St, Sacramento'); + assert.equal(result.flew, true); +}); + +test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order } = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const result = await runner('show_street_level', { + latitude: 38.58, + longitude: -121.49, + provider: 'mapillary', + fly: false, + }); + assert.equal(result.ok, true, result.error); + assert.deepEqual(order, ['params:mapillary:voice', 'open:mapillary:default:false']); + assert.equal(result.flew, false); +}); + +test('show_street_level without a source uses the one lit in the panel', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order } = createStreetLevelHarness({ + activeStack: 'photoreal', + lit: { mapillary: false, google: true }, + enabled: true, + }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, fly: false }); + assert.equal(result.ok, true, result.error); + assert.equal(result.provider, 'google'); + assert.deepEqual(order, ['params:google:voice', 'open:google:250:false']); +}); + +test('show_street_level reports each stage it could not pass', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const none = await centre.runner('show_street_level', { provider: 'google' }); + assert.equal(none.ok, true, 'no place at all means the view centre'); + assert.equal(none.label, 'the view centre'); + assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']); + + const unknown = await createStreetLevelHarness().runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false, + }); + assert.deepEqual([unknown.ok, unknown.stage], [false, 'provider']); + assert.match(unknown.error, /openstreetcam/); + + const keylessHarness = createStreetLevelHarness({ enabled: true, keyRequired: true }); + const keyless = await keylessHarness.runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'google', fly: false, + }); + assert.deepEqual([keyless.ok, keyless.stage], [false, 'key']); + assert.match(keyless.error, /needs its key/); + assert.equal(keyless.providerName, 'Google Street View'); + assert.deepEqual(keylessHarness.order, [], 'a keyless source changes nothing: no flight, no map, no switch'); + + const refusedSwitch = await createStreetLevelHarness({ enabled: true, paramsAccepted: false }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([refusedSwitch.ok, refusedSwitch.stage], [false, 'provider']); + assert.match(refusedSwitch.error, /could not be switched on/); + + const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']); + assert.match(empty.error, /within 250 m/); +}); + +test('show_street_level stops at the map when the source’s map is refused, before touching the panel', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { layer, order } = createStreetLevelHarness({ enabled: true }); + const reason = 'Google 3D needs GOOGLE_MAPS_API_KEY'; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true, google: false }), + setLayerParams: () => order.push('params'), + getAll: () => [], + }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + styleManager.mapStackController = { getActiveId: () => 'osm' }; + styleManager.setMapStack = async () => ({ ok: false, activeStack: 'osm', error: reason }); + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage, result.error], [false, 'map', reason]); + assert.deepEqual(order, [], 'the source is not switched when its map is refused'); +}); + +test('show_street_level opens photos of a numbered item or a pointed spot through its entity', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, finishFlight } = withCamera( + createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }), + ); + const pending = runner('show_street_level', { + provider: 'google', + entity: { layerId: 'cctv', id: 'cam-9', label: 'Camera 9', lat: 38.58, lon: -121.5 }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['fly'], 'the entity position is flown to'); + finishFlight(); + const result = await pending; + assert.equal(result.ok, true, result.error); + assert.equal(result.label, 'Camera 9'); + assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']); + // Without a flight the entity's own position is used. + const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' })); + const kept = await still.runner('show_street_level', { + provider: 'google', + fly: false, + entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 }, + }); + assert.equal(kept.ok, true, kept.error); + assert.deepEqual(still.order, ['params:google:voice', 'open:google:250:false']); +}); + +test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const points = []; + const make = () => { + const harness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const open = harness.layer.openNearest; + harness.layer.openNearest = async (point, options) => { + points.push(point); + return open(point, options); + }; + return harness; + }; + // A geocoded name: the flight reports where it landed. + const named = make(); + const viewerFor = () => ({ ...createVoiceNavigationHarness().viewer }); + const runnerWithSearch = createGevActionRunner({ + viewer: viewerFor(), + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: { + layers: new Map([['street-level', { module: named.layer }]]), + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true, google: false }), + setLayerParams: () => true, + getAll: () => [], + }, + searchNavigation: async (_viewer, query, options) => { + options.onComplete?.(); + return { label: `${query}, CA`, latitude: 38.5799, longitude: -121.4937, navigationMode: 'precise-place', rangeM: 250 }; + }, + }); + const geocoded = await runnerWithSearch('show_street_level', { query: '10th Street, Sacramento', provider: 'mapillary' }); + assert.equal(geocoded.ok, true, geocoded.error); + assert.equal(geocoded.label, '10th Street, Sacramento, CA'); + assert.deepEqual(points.at(-1), { lat: 38.5799, lon: -121.4937 }); + // A curated POI and a city preset fly to their hand-tuned poses and report them. + for (const [args, expected] of [ + [{ query: 'Golden Gate Bridge', provider: 'mapillary' }, { lat: 37.8199, lon: -122.4783 }], + [{ query: 'austin', provider: 'mapillary' }, null], + ]) { + const harness = make(); + const pending = harness.runner('show_street_level', args); + await new Promise((resolve) => setImmediate(resolve)); + harness.finishFlight(); + const result = await pending; + assert.equal(result.ok, true, `${args.query}: ${result.error}`); + const point = points.at(-1); + if (expected) assert.deepEqual(point, expected); + else assert.ok(Number.isFinite(point.lat) && Number.isFinite(point.lon), 'a preset flight reports its point'); + } +}); + +test('show_street_level retires its lookup when the turn is superseded, and says so', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, layer } = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const controller = new AbortController(); + let currentTurn = true; + layer.openNearest = async (_point, options) => { + order.push(`open:signal:${Boolean(options.signal)}`); + controller.abort(); + currentTurn = false; + return false; + }; + const result = await runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + { signal: controller.signal, isCurrent: () => currentTurn }, + ); + assert.deepEqual([result.ok, result.cancelled, result.stage], [false, true, 'imagery']); + assert.doesNotMatch(result.error, /No .* imagery/); + assert.ok(order.includes('open:signal:true'), 'the lookup gets the turn signal'); +}); + +test('show_street_level with autoOpen:false arms a billed source and leaves the load to a click; a free source still opens', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const armedHarness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const armed = await armedHarness.runner('show_street_level', { + latitude: 38.5799, longitude: -121.4937, provider: 'google', fly: false, autoOpen: false, + }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.equal(armed.imageId, undefined); + assert.deepEqual( + armedHarness.order, + ['map:photoreal', 'params:google:voice', 'arm:google:250:38.5799,-121.4937'], + 'map and source are ready, the lookup is armed at the point, nothing is loaded', + ); + assert.match(armed.say, /^Google Street View is ready at .*press OPEN/); + assert.deepEqual( + { ...armed, say: undefined, display: undefined }, + { ok: true, action: 'show_street_level', provider: 'google', providerName: 'Google Street View', label: '38.5799, -121.4937', armed: true, flew: false, say: undefined, display: undefined }, + ); + const free = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const opened = await free.runner('show_street_level', { + latitude: 38.5799, longitude: -121.4937, provider: 'mapillary', fly: false, autoOpen: false, + }); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.armed, undefined); + assert.equal(opened.imageId, 'pano-a'); +}); + +test('show_street_level tells a failed enable, a delisted source, a key refused mid-lookup and a layer switched off apart', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const at = { latitude: 1, longitude: 2, provider: 'google', fly: false }; + const notEnabled = await createStreetLevelHarness({ activeStack: 'photoreal', enableResult: false }).runner('show_street_level', at); + assert.deepEqual([notEnabled.ok, notEnabled.stage, notEnabled.cancelled], [false, 'layer', false]); + assert.match(notEnabled.say, /Couldn't turn on Street Level/); + + const delisted = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', listGoogle: false }).runner('show_street_level', at); + assert.deepEqual([delisted.ok, delisted.stage], [false, 'provider']); + assert.equal(delisted.error, 'not on this map'); + + const refused = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'key-rejected' }).runner('show_street_level', at); + assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]); + assert.equal(refused.say, 'Google Street View refused its key.'); + + const switchedOff = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'layer-off' }).runner('show_street_level', at); + assert.deepEqual([switchedOff.ok, switchedOff.stage, switchedOff.cancelled], [false, 'imagery', true]); + assert.equal(switchedOff.say, undefined, 'a request the user withdrew is not spoken'); + + // A source whose status call says it is not configured is key-gated even + // before its coverage reports a missing key. + const unconfigured = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', configured: false }); + const gated = await unconfigured.runner('show_street_level', at); + assert.deepEqual([gated.ok, gated.stage], [false, 'key']); + assert.deepEqual(unconfigured.order, [], 'nothing changed'); +}); + +test('show_street_level without a flight geocodes a name, locates an item, and reports what it cannot place', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const points = []; + const open = hit.layer.openNearest; + hit.layer.openNearest = (point, options) => { + points.push(point); + return open(point, options); + }; + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: hit.dataManager, + placeSearch: { + geocode: async (query) => + query === 'Capitol Mall' + ? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } } + : { place: null, answered: true }, + }, + }); + const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false }); + assert.equal(found.ok, true, found.error); + assert.equal(found.label, 'Capitol Mall, Sacramento'); + assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 }); + assert.equal(found.flew, false); + + const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false }); + assert.deepEqual([miss.ok, miss.stage], [false, 'location']); + assert.equal(miss.say, "Couldn't find Atlantis Boulevard."); + assert.equal(points.length, 1, 'a miss asks for no imagery'); + + const gone = await runner('show_street_level', { + provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' }, + }); + assert.deepEqual([gone.ok, gone.stage], [false, 'location']); + assert.equal(gone.say, "Couldn't find Camera 7."); + + // A flight that reports no coordinates cannot be followed by a lookup. + const noPoint = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: hit.dataManager, + searchNavigation: async (_viewer, query, options) => { + options.onComplete?.(); + return { label: query, navigationMode: 'city-overview', rangeM: null }; + }, + }); + const unplaced = await noPoint('show_street_level', { query: 'Somewhere', provider: 'mapillary' }); + assert.deepEqual([unplaced.ok, unplaced.stage], [false, 'location']); + assert.match(unplaced.error, /no position/); +}); + +test('fly_to_location reports the overview centre for a city framed as a whole, and the first POI for a close view', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + viewer.camera.lookAt = () => {}; + viewer.camera.flyTo = (options) => options.complete?.(); + viewer.camera.flyToBoundingSphere = (_s, options) => options.complete?.(); + styleManager.runImmediateLocationNavigation = (navigate) => styleManager.runImmediateNavigation('location', navigate); + const runner = createGevActionRunner({ viewer, styleManager, dataManager: { layers: new Map(), getAll: () => [] } }); + const overview = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(overview.ok, true); + assert.ok(Math.abs(overview.latitude - 30.31) < 0.001, String(overview.latitude)); + assert.ok(Math.abs(overview.longitude - -97.75) < 0.001, String(overview.longitude)); + const close = await runner('fly_to_location', { locationId: 'austin', viewMode: 'close' }); + assert.deepEqual([close.latitude, close.longitude], [30.2747, -97.7403]); +}); + +test('show_street_level arms the view centre when a billed link names no place, and keeps the flight label when it flew', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const armed = await centre.runner('show_street_level', { provider: 'google', autoOpen: false }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.deepEqual(centre.order, ['params:google:voice', 'arm:google:250:view-centre'], 'armed at the view centre, nothing opened'); + const flown = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const pending = flown.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google', autoOpen: false }); + await new Promise((resolve) => setImmediate(resolve)); + flown.finishFlight(); + const result = await pending; + assert.equal(result.armed, true); + assert.equal(result.flew, true); + assert.equal(result.location, '38.5799, -121.4937'); + assert.ok(!flown.order.some((entry) => entry.startsWith('open:')), 'no load after the flight'); +}); + +test('show_street_level says "refused its key" when Google already refused it, and fails when the source will not light', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const refusedHarness = createStreetLevelHarness({ enabled: true, keyRejectedAtStart: true }); + const refused = await refusedHarness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }); + assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]); + assert.equal(refused.error, KEY_REJECTED_MESSAGE); + assert.equal(refused.say, 'Google Street View refused its key.'); + assert.deepEqual(refusedHarness.order, [], 'nothing changed'); + const dark = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', lightFails: true }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([dark.ok, dark.stage], [false, 'provider']); + assert.match(dark.error, /could not be switched on/); +}); + +test('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + // The referent carries the coordinates the list saved, but it is no longer loaded. + const stale = await harness.runner('show_street_level', { + provider: 'google', + fly: false, + entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 }, + }); + assert.deepEqual([stale.ok, stale.stage], [false, 'location']); + assert.match(stale.error, /no longer loaded/); + assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point'); +}); + +test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => { + const { activeImageryFilter } = await import('./gevActions.js'); + const both = { pano: true, since: true }; + assert.equal(activeImageryFilter({ filter: { pano: 'all', sinceDays: 0 }, filterControls: both }), null); + assert.deepEqual(activeImageryFilter({ filter: { pano: 'pano', sinceDays: 0 }, filterControls: both }), { + pano: 'pano', + label: '360° filter', + }); + assert.deepEqual(activeImageryFilter({ filter: { pano: 'flat', sinceDays: 365 }, filterControls: both }), { + pano: 'flat', + sinceDays: 365, + label: 'flat-photo and SINCE filters', + }); + assert.equal( + activeImageryFilter({ filter: { pano: 'pano', sinceDays: 365 }, filterControls: { pano: false, since: false } }), + null, + 'Street View honours neither', + ); + assert.equal(activeImageryFilter(undefined), null); +}); + test('set_panel_open accepts the Authoring name for the scene panel', async () => { const calls = []; const runner = createGevActionRunner({ diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 962bf6949..44801dc0a 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -352,7 +352,12 @@ const layers = [ }, { id: 'street-level', - aliases: ['street level', 'street-level imagery', 'mapillary'], + aliases: [ + 'street level', + 'street-level imagery', + 'mapillary', + 'street view', + ], context: false, noQuery: 'street-level imagery; no countable records', }, diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index ab382c460..b35d83cc5 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -119,6 +119,7 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never 'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations', 'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches', 'street level': 'street-level', mapillary: 'street-level', + 'street view': 'street-level', wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters', 'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters', }; diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 5a3a64fdc..2a5dae764 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -27,6 +27,7 @@ const CAMERA_TOOLS = new Set([ 'fly_route', 'frame_overhead', 'select_nearest_aircraft', + 'show_street_level', 'control_cockpit', ]); diff --git a/src/voice/speech.js b/src/voice/speech.js index a0f65b573..f481d1338 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -24,6 +24,7 @@ import { analystHeadline } from './resultDisplay.js'; import { + formatDate, formatNumber, getLocale, msg, @@ -564,6 +565,87 @@ function selectNearestAircraft(result) { }; } +/** Month and year an image was captured, or ''. */ +function capturedMonth(capturedAt) { + const ms = Number(capturedAt); + if (!Number.isFinite(ms) || ms <= 0) return ''; + try { + // Dates are UTC months (Google: the first of the month). + return formatDate(new Date(ms), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } catch { + return ''; + } +} + +function showStreetLevel(result) { + // `location` is the flight's own result on a failed flight; its label is + // the place, never the object. + const place = spokenLabel( + result?.label || + (typeof result?.location === 'string' + ? result.location + : result?.location?.label) || + 'there', + ); + const source = spokenLabel(result?.providerName || 'Street Level'); + // The card in the interface language; the spoken line stays English. + const shownSource = result?.providerName + ? spokenLabel(result.providerName) + : t('Street Level'); + const shown = spokenLabel(localizePlaceName(result?.label || '')) || place; + if (result?.cancelled) return null; + if (!result?.ok) { + let say; + if (result?.stage === 'location') say = `Couldn't find ${place}.`; + else if (result?.stage === 'map') + say = `${source} needs the Google 3D map, which isn't available.`; + else if (result?.stage === 'key') + say = result?.keyRejected + ? `${source} refused its key.` + : `${source} needs its key.`; + else if (result?.stage === 'layer') say = "Couldn't turn on Street Level."; + else if (result?.stage === 'imagery') + say = result?.filter?.label + ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` + : `No ${source} imagery near ${place}.`; + else say = `Couldn't open ${source}.`; + return { + say, + display: { + title: shownSource, + lines: errorLines(result), + sources: [], + }, + }; + } + if (result.armed === true) + return { + say: sentence(`${source} is ready at ${place}: press OPEN to load it`), + display: { + title: shownSource, + lines: [shown, t('Press OPEN to load the photo')], + sources: [{ label: shownSource }], + }, + }; + const title = spokenLabel(result.title || '', 60); + const month = capturedMonth(result.capturedAt); + return { + say: sentence(`${source} at ${title || place}`), + display: { + title: shownSource, + lines: [ + title || shown, + month ? t('Captured {month}', { month }) : null, + ].filter(Boolean), + sources: [{ label: shownSource }], + }, + }; +} + const SPOKEN_ROUTE = Object.freeze({ between: (origin, destination) => `${origin} to ${destination}`, from: (origin) => `from ${origin}`, @@ -771,6 +853,7 @@ export const SPEECH_BUILDERS = Object.freeze({ fly_to_location: flyToLocation, frame_overhead: frameOverhead, select_nearest_aircraft: selectNearestAircraft, + show_street_level: showStreetLevel, get_entity_context: getEntityContext, get_current_view_state: getCurrentViewState, annotate_map: annotateMap, @@ -826,6 +909,14 @@ function annotationTargets(args) { .filter(Boolean); } +/** Street-level sources as a plan step names them. */ +const STREET_LEVEL_SOURCE_NAMES = Object.freeze({ + google: 'Street View', + mapillary: 'Mapillary', + kartaview: 'KartaView', + panoramax: 'Panoramax', +}); + // frame_overhead targets as the schema names them. function frameStepLabel(target) { switch (target) { @@ -897,6 +988,17 @@ export function planStepLabel(name, args = {}) { ? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) }) : t('Nearest aircraft to Here'); } + case 'show_street_level': { + const source = STREET_LEVEL_SOURCE_NAMES[args.provider]; + const place = args.query + ? shownPlace(args.query, 32) + : args.latitude != null + ? t('this spot') + : t('the view centre'); + return source + ? t('{source} at {place}', { source, place }) + : t('Street level at {place}', { place }); + } case 'set_layer_visibility': { const on = args.enabled !== false; const label = @@ -940,6 +1042,8 @@ export function narrationLabel(name, args = {}) { presetPlaceName(args.locationQuery || args.locationId || ''), 32, ); + case 'show_street_level': + return shownPlace(args.query || '', 32); default: return ''; } @@ -954,6 +1058,8 @@ const STEP_LABELS = Object.freeze({ layer: msg('Turning on layer'), refresh: msg('Loading aircraft'), nearest: msg('Picking nearest'), + map: msg('Switching map'), + imagery: msg('Opening imagery'), }); /** On-screen label for a progress step. */ @@ -988,6 +1094,12 @@ export function progressLine(step, label) { return 'Loading aircraft there.'; case 'nearest': return 'Picking the nearest aircraft.'; + case 'map': + return place === 'photoreal' + ? 'Switching to Google 3D.' + : 'Switching the map.'; + case 'imagery': + return place ? `Looking for imagery at ${place}.` : null; case 'still': return place ? `Still working on ${place}.` : 'Still working on it.'; default: diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index 04b04d9b4..eaaaed4cb 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -489,6 +489,154 @@ test('labels are bounded and cleaned before they are spoken', () => { ); }); +test('street-level speech names the source and the place, and says which step failed', () => { + const ok = envelope('show_street_level', { + ok: true, + action: 'show_street_level', + provider: 'google', + providerName: 'Google Street View', + label: '10th Street, Sacramento', + title: '1000 10th St, Sacramento, CA', + capturedAt: Date.UTC(2024, 4, 1), // Google dates: the first of the month, UTC + }); + assert.equal(ok.say, 'Google Street View at 1000 10th St, Sacramento, CA.'); + assertSpeakable(ok.say); + assert.equal(ok.display.title, 'Google Street View'); + assert.equal(ok.display.lines[1], 'Captured May 2024'); + + const stages = { + location: "Couldn't find Nowhere.", + map: "Google Street View needs the Google 3D map, which isn't available.", + key: 'Google Street View needs its key.', + layer: "Couldn't turn on Street Level.", + imagery: 'No Google Street View imagery near Nowhere.', + }; + for (const [stage, say] of Object.entries(stages)) { + const out = envelope('show_street_level', { + ok: false, + stage, + label: 'Nowhere', + providerName: 'Google Street View', + error: 'detail', + }); + assert.equal(out.say, say, stage); + assertSpeakable(out.say); + assert.deepEqual(out.display.lines, ['detail']); + } + const armed = envelope('show_street_level', { + ok: true, + armed: true, + providerName: 'Google Street View', + label: '10th St, Sacramento', + }); + assert.equal(armed.say, 'Google Street View is ready at 10th St, Sacramento: press OPEN to load it.'); + assertSpeakable(armed.say); + assert.deepEqual(armed.display.lines, ['10th St, Sacramento', 'Press OPEN to load the photo']); + const unfound = envelope('show_street_level', { + ok: false, + stage: 'location', + location: { ok: false, action: 'fly_to_location', label: 'Nowhere Town' }, + }); + assert.equal(unfound.say, "Couldn't find Nowhere Town."); + assert.equal(unfound.display.title, 'Street Level'); + const refused = envelope('show_street_level', { + ok: false, + stage: 'key', + keyRejected: true, + providerName: 'Google Street View', + }); + assert.equal(refused.say, 'Google Street View refused its key.'); + assert.equal( + envelope('show_street_level', { ok: false, cancelled: true }).say, + undefined, + ); + assert.equal( + planStepLabel('show_street_level', { + query: 'Eiffel Tower', + provider: 'google', + }), + 'Street View at Eiffel Tower', + ); + assert.equal( + planStepLabel('show_street_level', { + query: 'Times Square', + provider: 'mapillary', + }), + 'Mapillary at Times Square', + ); + assert.equal( + progressStepLabel('imagery', 'Eiffel Tower'), + 'Opening imagery: Eiffel Tower', + ); + assert.equal(progressLine('map', 'photoreal'), 'Switching to Google 3D.'); + assert.equal( + progressLine('imagery', 'Eiffel Tower'), + 'Looking for imagery at Eiffel Tower.', + ); +}); + +test('the capture month is a UTC month whatever the timezone, and labels name the place', async () => { + const { execFileSync } = await import('node:child_process'); + const speech = new URL('./speech.js', import.meta.url).href; + // Google dates a panorama 2024-05 as the first of the month, UTC. West of + // Greenwich that instant is still April; the card must still say May. + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { attachVoiceResult } from ${JSON.stringify(speech)}; + const r = attachVoiceResult('show_street_level', { ok: true, providerName: 'Google Street View', label: 'x', capturedAt: Date.UTC(2024, 4, 1) }); + process.stdout.write(r.display.lines[1]);`, + ], + { env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' }, + ); + assert.equal(out, 'Captured May 2024'); + const { narrationLabel } = await import('./speech.js'); + assert.equal(narrationLabel('show_street_level', { query: 'Eiffel Tower' }), 'Eiffel Tower'); + assert.equal(narrationLabel('show_street_level', {}), ''); + assert.equal( + planStepLabel('show_street_level', { latitude: 1, longitude: 2 }), + 'Street level at this spot', + ); + assert.equal(planStepLabel('show_street_level', {}), 'Street level at the view centre'); +}); + +test('the plan step names KartaView as a source', () => { + assert.equal( + planStepLabel('show_street_level', { + query: 'Alexanderplatz', + provider: 'kartaview', + }), + 'KartaView at Alexanderplatz', + ); +}); + +test('an empty street-level search names the imagery filter that hid the photos', () => { + const out = envelope('show_street_level', { + ok: false, + stage: 'imagery', + label: 'Alexanderplatz', + providerName: 'KartaView', + filter: { pano: 'pano', label: '360° filter' }, + }); + assert.equal( + out.say, + 'No KartaView imagery near Alexanderplatz with the 360° filter on.', + ); + assertSpeakable(out.say); +}); + +test('the plan step names Panoramax as a source', () => { + assert.equal( + planStepLabel('show_street_level', { + query: 'Montmartre', + provider: 'panoramax', + }), + 'Panoramax at Montmartre', + ); +}); + test('progress step labels join through the catalog colon', () => { setLocale('fr'); try { From ed2703d345e88f702f0d59e07f821ff82ee4b60b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 016/181] feat(street-level): restore Mapillary's nearest-image lookup for show_street_level and sl= links The slim Street Level core (#974) removed nearestImage from the Mapillary provider. show_street_level and sl=mapillary links need it to open the photo closest to a point, so this restores it on the slim core: Graph API /images within 50 m, sorted by distance, first one that passes the imagery filter. Kept as its own commit so it can be dropped if #974's removal should stand. --- .../fixtures/street-level/mapillaryGraph.mjs | 12 ++++++ .../streetLevel/providers/mapillary/index.js | 36 +++++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 10 +++++ .../providers/mapillary/provider.test.mjs | 39 +++++++++++++++++++ .../streetLevel/providers/mapillary/source.js | 22 +++++++++++ .../providers/mapillary/source.test.mjs | 22 +++++++++++ src/testSupport/streetLevelFakes.mjs | 1 + 7 files changed, 142 insertions(+) diff --git a/scripts/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs index a18da379f..a978a0539 100644 --- a/scripts/fixtures/street-level/mapillaryGraph.mjs +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) { : []; return { ...json(200, { data }), known: true }; } + if (q.has('lat') && q.has('lng')) { + const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + const radius = Math.min(50, Number(q.get('radius')) || 50); + const limit = Number(q.get('limit')) || 10; + // In no particular order, as the real API answers. + const data = photos + .filter((image) => metresApart(at, image) <= radius) + .reverse() + .slice(0, limit) + .map((image) => withFields(imageRecord(image), fields)); + return { ...json(200, { data }), known: true }; + } if (q.has('s2')) return { ...json(200, { data: [] }), known: true }; return { ...json(400, { error: 'unsupported images query' }), diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 901df886c..c47280e9e 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -5,6 +5,9 @@ import { } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { coverageZoomForHeight } from '../../tileMath.js'; +import { passesImageryFilter } from '../../filter.js'; +import { NEAREST_RADIUS_M } from '../../policy.js'; +import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; import { @@ -17,6 +20,7 @@ import { MAPILLARY_NAME, MAPILLARY_PROVIDER_ID, MAPILLARY_SOURCE_METHODS, + NEAREST_LIMIT, PICK_PREFIX, RATE_LIMITED_MESSAGE, } from './policy.js'; @@ -193,6 +197,38 @@ export function createMapillaryProvider({ source, photoViewer = null }) { }), coverageLines: () => parts.coverage.drawnLines(), + /** + * The nearest image that passes the imagery filter, or null: what + * show_street_level and sl= links open (clicks open a line, then a + * cone). The core aborts `signal` once a newer request overtakes it. + */ + async nearestImage({ lat, lon }, { signal } = {}) { + const images = await source.nearestImages( + { lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT }, + { signal }, + ); + // The API returns the images in the radius in no particular order. + const metres = (record) => { + const [lon2, lat2] = (record.computed_geometry || record.geometry) + ?.coordinates || [NaN, NaN]; + const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 }); + return Number.isFinite(d) ? d : Infinity; + }; + const filter = context.getFilter(); + const hit = [...images] + .sort((a, b) => metres(a) - metres(b)) + .find((record) => + passesImageryFilter( + { + isPano: record.is_pano === true, + capturedAt: Number(record.captured_at) || 0, + }, + filter, + ), + ); + return hit ? String(hit.id) : null; + }, + viewer: photoViewer || createMapillaryViewer({ source, render: context.services?.render }), diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 183082934..b1609983a 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([ 'getStatus', 'getTile', 'getSequenceImages', + 'nearestImages', ]); /** Stable id prefixes for picked primitives; every one starts with `mly:`. */ @@ -72,3 +73,12 @@ export function mapillaryImageUrl(imageId) { /** Fields requested for a sequence's images. */ export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano'; + +/** + * Images asked for in a nearest-image search (show_street_level and links + * open the photo nearest a point; the graph API caps the radius at 50 m). + */ +export const NEAREST_LIMIT = 8; +/** Fields a nearest-image search needs: where each is, and the filter's. */ +export const NEAREST_IMAGE_FIELDS = + 'id,captured_at,geometry,computed_geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index ce2f0bb0c..ae406df32 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -170,3 +170,42 @@ test('a status the server never answers falls back to the bundled token once the assert.deepEqual(await status, { configured: false }); assert.equal(instance.coverageStats().keyRequired, true); }); + +/** A graph image record at a point. */ +const imageAt = (id, lon, lat, extra = {}) => ({ + id, + is_pano: false, + captured_at: 10, + geometry: { type: 'Point', coordinates: [lon, lat] }, + ...extra, +}); + +test('nearestImage opens the closest image that passes the filter, not the first the API returned', async () => { + const requests = []; + const answer = [ + imageAt('far', 2.0004, 1), // ~45 m east + imageAt('near-pano', 2.00002, 1, { is_pano: true }), // ~2 m, a panorama + imageAt('near', 2.00005, 1), // ~5 m east + imageAt('mid', 2, 1.0002), // ~22 m north + ]; + const source = fakeMapillarySource({ + nearestImages: async (query, options) => { + requests.push({ query, options }); + return answer; + }, + }); + const { signal } = new AbortController(); + const flat = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'flat', sinceMs: null }), + ); + assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); + assert.equal(requests[0].options.signal, signal, 'the core can abort it'); + assert.equal(requests[0].query.radius, 50); + assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]); + const any = createMapillaryProvider({ source }).create(fakeContext()); + assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano'); + const tooOld = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'all', sinceMs: 100 }), + ); + assert.equal(await tooOld.nearestImage({ lat: 1, lon: 2 }), null); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 38a8b9c39..022a14dac 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,5 +1,8 @@ +import { NEAREST_RADIUS_M } from '../../policy.js'; import { MAPILLARY_GRAPH_HOST, + NEAREST_IMAGE_FIELDS, + NEAREST_LIMIT, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -147,5 +150,24 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, + + /** Images within `radius` metres of a point, in no particular order. */ + async nearestImages( + { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, + { signal } = {}, + ) { + const payload = await graph( + 'images', + { + lat: String(lat), + lng: String(lon), + radius: String(Math.min(50, Math.max(1, radius))), + limit: String(Math.min(100, Math.max(1, limit))), + fields: NEAREST_IMAGE_FIELDS, + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, }; } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index b502a7fd8..883918f4f 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -126,6 +126,28 @@ test('a rejected key and a rate limit are told apart from other tile errors', as ); }); +test('nearestImages asks the graph API around a point, its radius and limit clamped', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const images = await source.nearestImages({ + lat: 38.5, + lon: -121.5, + radius: 500, + limit: 500, + }); + assert.equal(images.length, 1); + const url = new URL(calls[0].url); + assert.equal(url.pathname, '/images'); + assert.equal(url.searchParams.get('lat'), '38.5'); + assert.equal(url.searchParams.get('lng'), '-121.5'); + assert.equal(url.searchParams.get('radius'), '50', 'the API maximum'); + assert.equal(url.searchParams.get('limit'), '100'); + assert.match(url.searchParams.get('fields'), /computed_geometry/); + assert.match(url.searchParams.get('fields'), /is_pano/); +}); + test('with a Graph endpoint, sequence lookups go there without a token', async () => { const { calls, fetchImpl } = fakeFetch(() => jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index f6d5a6d70..a14825d80 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) { return new Uint8Array(0); }, getSequenceImages: async () => [], + nearestImages: async () => [], ...overrides, }; } From fcf240e7890ea55f4c9c72105d86621aba4a22a8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 017/181] test(voice): raise the Realtime prompt budget to 53,000 for show_street_level Main's prompt sits 28 characters under the 52,000 ceiling, and the street-level tool needs 337 even with no descriptions. Kept as its own commit so the budget decision can be reverted on its own. --- src/voice/pointAndAsk.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 10af85166..612c71133 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -343,9 +343,11 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // Budget: the whole Realtime prompt (instructions + tools), measured in // characters. It was 56,308 after the area/imagery/OSM merge; stating each // policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought - // it to 51,646. A new tool or rule has to fit under this ceiling. + // it to 51,646. A new tool or rule has to fit under this ceiling. Raised + // to 53,000 for show_street_level: globe rotation left 28 characters, and + // the street-level tool alone needs 337 with no descriptions at all. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; - assert.ok(size <= 52000, `prompt size ${size}`); + assert.ok(size <= 53000, `prompt size ${size}`); }); From 0ec57057fa2bf6692cdbe8f608984b2729c27906 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 15:30:48 +0000 Subject: [PATCH 018/181] fix(street-level): review fixes for show_street_level and sl= links P1: on a phone the Street Level panel sits in the Cameras tab sheet, so the action and sl= links opened photos nobody could see and still reported success. A photo that opens, and a link that arms, now open that sheet (revealPanel). In cockpit, clean view, recording or scene playback the action stops at a new `view` stage before anything switches or loads. P2s: - Mapillary's nearest search asks for 100 photos, the API's most. It cuts the radius's photos at the limit in no particular order, so 8 left the distance sort and the imagery filter a few arbitrary photos. - The MCP globe panel opens a billed photo only for a result issued in the last five minutes (show_in_gods_eye_view stamps issued_at); a host that mounts the panel again hands back the old result, which only arms. - OPEN uses an armed link's point only while the view centre is within 1 km of it; a user who moved on opens what the view shows. - show_in_gods_eye_view measures a camera's altitude from the terrain under it before taking its look-at point, so high cities no longer put the photos kilometres too far ahead. - The armed-link toast is translated, and the new view-stage message too, in all 11 catalogs. - Tests: a turn superseded during the geocode, flight or map switch changes nothing; an unknown key state lets a cold sl= link through; a wide tilted area still opens photos at its centre. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 9 +- docs/CURRENT-STATE.md | 25 ++++-- src/app/embed.js | 30 +++++-- src/app/embed.test.mjs | 66 ++++++++------ src/app/globePanelRuntime.js | 15 +++- src/i18n/locales/ar.json | 4 +- src/i18n/locales/de.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/uk.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-Hant.json | 4 +- src/layers/streetLevel/index.js | 16 +++- src/layers/streetLevel/index.test.mjs | 64 ++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 5 +- .../providers/mapillary/provider.test.mjs | 3 + src/tools/queries/share.js | 48 ++++++++++- src/tools/queries/share.test.mjs | 70 +++++++++++++++ src/ui/applicationShell.js | 14 ++- src/ui/streetLevelControls.js | 7 +- src/ui/streetLevelControls.test.mjs | 17 ++++ src/view/index.js | 2 +- src/voice/gevActions.js | 13 +++ src/voice/gevActions.test.mjs | 86 +++++++++++++++++++ src/voice/speech.js | 2 + 30 files changed, 473 insertions(+), 65 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8c43bcee5..797793cf0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,7 +129,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp` - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. -- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link, are that client's own request and open the photo; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. +- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. ## Scope & expectations diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index 36cf415df..76bd35de3 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -6,7 +6,14 @@ `show_in_gods_eye_view` and a link can open a panorama at a place. Street View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A plain link with `sl=google` arms Street View and leaves the billed load to a - press of OPEN GOOGLE STREET VIEW; embedded views open it. The source the + press of OPEN GOOGLE STREET VIEW, which aims at the view centre again once + the visitor has moved more than 1 km away; embedded views open it, and the + God's Eye View panel only for a result issued in the last five minutes. On + a phone, a photo that opens or a link that arms opens the Cameras sheet; + in cockpit, clean view, recording or scene playback the action says the + panel is hidden instead of loading a photo nobody sees. Mapillary's + nearest-photo search asks for 100 photos, so the nearest and the filter's + pick come from the spot's photos rather than an arbitrary eight. The source the action lights stays lit like a chip click. An unavailable map stack now reports the controller's own reason instead of always asking for a Cesium ion token. When nothing is found and a 360°, flat or SINCE filter the source diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 11b1bd16e..437f32c33 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4339,14 +4339,20 @@ centre), switches to the map the source needs (Google Street View: Google 3D), lights that source through `setLayerParams` with origin `voice` (one source at a time, kept in the share link like a chip click), turns the layer on and calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, -`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `map`, -`provider`, `layer`, `key`, `imagery`; an empty `imagery` result names a +`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, +`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, +recording or scene playback hides the panel, checked after the flight and +before anything switches or loads; an empty `imagery` result names a narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. For a source without a coverage layer the lookup widens to 250 m (`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); -clicks keep 50 m. The layer exposes `requiredMapStack(id)` and +clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it +returns the radius's photos in no particular order and cuts them at the limit +first, so the distance sort and the imagery filter run over the spot's photos. +On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a +link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and `providerStatus(id)` for callers that must decide before the panel lists a source, and `coverageLines(id)` (the lines a provider draws now, current and stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, @@ -4369,13 +4375,20 @@ link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's point and radius, consumed only by the next OPEN press (`aim: -'screen-centre'`); any other lookup, open, close, disable, its source +'screen-centre'`), and only while the view centre is within 1 km of the point +(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source switched off or its map leaving drops it), opens the panel and a toast points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline -embed) and posted views open the photo; `?embed=1` on a published link only +embed) for a tool result issued in the last 5 minutes, and posted views, open +the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`, +and a host that mounts the panel again later hands back the old result, which +only arms; `?embed=1` on a published link only arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`. The look-at default applies below 20 km, at a pitch steeper than -5° and -within 10 km ahead; otherwise the camera's own point is used. +within 10 km ahead; otherwise the camera's own point is used. A view's +altitude is above the ellipsoid, so `show_in_gods_eye_view` measures it from +the terrain under the camera (`services.terrain`) before taking the look-at +point; without a terrain answer it keeps the view's own estimate. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/src/app/embed.js b/src/app/embed.js index a3dec5b84..8f439bf41 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -13,6 +13,8 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { t, translateKnown } from '../i18n/index.js'; +import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js'; import { annotationsFromParams, createView, @@ -51,6 +53,14 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +/** + * Whether the panel page started the app for a tool result issued just now, + * not one its host handed back on mounting the panel again. + */ +function isFreshPanelResult() { + return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true; +} + /** * `doc`'s visibility for work that pauses in a hidden page. A panel's host * may report it hidden while it is on screen (see keepPanelRendering), so an @@ -188,8 +198,13 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { } const STREET_LEVEL_PANEL_ID = 'street-level-panel'; -const ARMED_TOAST = - 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it'; + +/** The armed link's toast, naming the OPEN button as the panel shows it. */ +function armedToast() { + return t('Street View is ready: press {open} to load it', { + open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }), + }); +} /** * Make the app show a view: style and map, exactly the view's layers, @@ -323,8 +338,9 @@ export function installViews({ queue = linkStep(() => run('annotate_map', { annotations: linked })); // A link to street-level photos opens them once the link has restored. A // link anyone can publish stops short of a billed load and leaves it to a - // click; only the panel page's own link (inline embed) is the client's - // request. A link that follows an entity keeps the camera on it. + // click; only the panel page's own link for a result issued just now is + // the client's request (a host remounting the panel hands back old ones). + // A link that follows an entity keeps the camera on it. const streetLevel = followFromParams(hashParams) ? null : streetLevelFromParams(hashParams); @@ -333,7 +349,7 @@ export function installViews({ if (navigatedSinceRestore()) return; const result = await run( 'show_street_level', - streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }), + streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }), ); // The restore window keeps the panel collapsed; OPEN must be in view. // Embed mode hides the rail that holds it, so there the link only arms @@ -342,7 +358,9 @@ export function installViews({ shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { explicit: true, }); - shell._showToast?.(ARMED_TOAST); + // On a phone the panel sits in a tab sheet: open it so OPEN shows. + shell.revealPanel?.(STREET_LEVEL_PANEL_ID); + shell._showToast?.(armedToast()); } }); if (!isEmbedded(location)) return () => {}; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index f7aae019e..fb70315ff 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -540,6 +540,7 @@ test('a failing link step does not take the next one with it', async () => { test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => { const toasts = []; const panels = []; + const revealed = []; const { calls, run } = recorder({ show_street_level: () => ({ ok: true, armed: true }), }); @@ -549,6 +550,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s _showToast: (text) => toasts.push(text), setPanelCollapsed: (id, collapsed, options) => panels.push([id, collapsed, options]), + revealPanel: (id) => revealed.push(id), }, viewer: fakeViewer(), dataManager: fakeLayers(new Set()), @@ -569,6 +571,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s [['street-level-panel', false, { explicit: true }]], 'the panel opens so OPEN is in view', ); + assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too'); // Adding ?embed=1 to a published link does not lift the gate: the page is // still a plain top-level load. const framedToasts = []; @@ -599,38 +602,45 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s assert.equal(framed.calls[0][1].autoOpen, false); // In a frame the rail that holds OPEN is hidden: no nudge, no toast. assert.deepEqual([framedToasts, framedPanels], [[], []]); - // Only the panel page's own link (inline embed) is the client's request. + // Only the panel page's own link for a result issued just now is the + // client's request: a host that mounts the panel again hands back an old + // result, and that one only arms, like any link. globalThis.GEV_EMBED_INLINE = true; try { - const inline = recorder({ show_street_level: () => ({ ok: true }) }); - const inlineWindow = new EventTarget(); - inlineWindow.document = { body: { classList: { add() {} } } }; - inlineWindow.postMessage = () => {}; - // The inline panel keeps rendering from a timer; give it inert clocks. - Object.assign(inlineWindow, { - requestAnimationFrame: () => 1, - cancelAnimationFrame() {}, - setTimeout: () => 1, - clearTimeout() {}, - setInterval: () => 1, - clearInterval() {}, - }); - const stop = installViews({ - shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, - viewer: fakeViewer(), - dataManager: fakeLayers(new Set()), - run: inline.run, - location: { - search: '', - hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', - }, - windowRef: inlineWindow, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal('autoOpen' in inline.calls[0][1], false); - stop(); + for (const fresh of [true, false]) { + globalThis.GEV_EMBED_FRESH = fresh; + const inline = recorder({ show_street_level: () => ({ ok: true }) }); + const inlineWindow = new EventTarget(); + inlineWindow.document = { body: { classList: { add() {} } } }; + inlineWindow.postMessage = () => {}; + // The inline panel keeps rendering from a timer; give it inert clocks. + Object.assign(inlineWindow, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setTimeout: () => 1, + clearTimeout() {}, + setInterval: () => 1, + clearInterval() {}, + }); + const stop = installViews({ + shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: inline.run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef: inlineWindow, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false); + else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms'); + stop(); + } } finally { delete globalThis.GEV_EMBED_INLINE; + delete globalThis.GEV_EMBED_FRESH; } // applyView (a posted view) opens too. const posted = recorder(); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index d10e5717d..a19724aa2 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -462,8 +462,11 @@ export function panelRuntime(config) { * Load God's Eye View into this page from the app's panel build, in * inline embed mode, starting at the first view as its link would. */ - async function startApp(url, view) { + async function startApp(url, view, fresh = false) { window.GEV_EMBED_INLINE = true; + // Whether this result was just issued, not handed back by a host that + // mounted the panel again: only then may its link open a billed photo. + window.GEV_EMBED_FRESH = fresh; window.GEV_APP_BASE_URL = config.appBaseUrl; try { history.replaceState( @@ -551,7 +554,9 @@ export function panelRuntime(config) { ); } - // Tool results: the first view starts the app, later ones move it. + // Tool results: the first view starts the app, later ones move it. A + // result issued longer ago than this was handed back on a remount. + const FRESH_RESULT_MS = 5 * 60 * 1000; let started = false; let ready = false; let queued = null; @@ -571,7 +576,11 @@ export function panelRuntime(config) { started = true; // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); - startApp(url, view).catch((error) => + const issuedAt = Number(data.issued_at); + const fresh = + Number.isFinite(issuedAt) && + Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS; + startApp(url, view, fresh).catch((error) => // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index beb7d81d9..04db610d5 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3232,5 +3232,7 @@ "{source} at {place}": "{source} في {place}", "Street level at {place}": "مستوى الشارع في {place}", "Switching map": "جارٍ تبديل الخريطة", - "Opening imagery": "جارٍ فتح الصور" + "Opening imagery": "جارٍ فتح الصور", + "Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله", + "Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا." } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f419eb6bb..06a65043b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2952,5 +2952,7 @@ "{source} at {place}": "{source} bei {place}", "Street level at {place}": "Straßenebene bei {place}", "Switching map": "Karte wird gewechselt", - "Opening imagery": "Bilder werden geöffnet" + "Opening imagery": "Bilder werden geöffnet", + "Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden", + "Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9f90a6bf0..63c2b1380 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3022,5 +3022,7 @@ "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos", "Sea Marks": "Señales marítimas", - "Ski Pistes": "Pistas de esquí" + "Ski Pistes": "Pistas de esquí", + "Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo", + "Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero." } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3f5e83efd..d8dbfb58b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3022,5 +3022,7 @@ "{source} at {place}": "{source} à {place}", "Street level at {place}": "Niveau rue à {place}", "Switching map": "Changement de carte", - "Opening imagery": "Ouverture des images" + "Opening imagery": "Ouverture des images", + "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", + "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 64cd39b5b..d81be9678 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3022,5 +3022,7 @@ "{source} at {place}": "{source} a {place}", "Street level at {place}": "Vista stradale a {place}", "Switching map": "Cambio mappa", - "Opening imagery": "Apertura delle immagini" + "Opening imagery": "Apertura delle immagini", + "Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo", + "Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5df8426c6..50e87e4e5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2882,5 +2882,7 @@ "{source} at {place}": "{place}の{source}", "Street level at {place}": "{place}のストリートレベル", "Switching map": "地図を切り替え中", - "Opening imagery": "画像を開いています" + "Opening imagery": "画像を開いています", + "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます", + "Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9b8f92221..b7b246c4c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3022,5 +3022,7 @@ "{source} at {place}": "{source} em {place}", "Street level at {place}": "Nível da rua em {place}", "Switching map": "Mudando de mapa", - "Opening imagery": "Abrindo imagens" + "Opening imagery": "Abrindo imagens", + "Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar", + "Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 302b725a6..9491622ed 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3092,5 +3092,7 @@ "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Sea Marks": "Морские знаки", - "Ski Pistes": "Горнолыжные трассы" + "Ski Pistes": "Горнолыжные трассы", + "Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить", + "Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index b1c477f46..6c4489195 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3092,5 +3092,7 @@ "{source} at {place}": "{source}: {place}", "Street level at {place}": "Вуличні знімки: {place}", "Switching map": "Перемикання карти", - "Opening imagery": "Відкриття знімків" + "Opening imagery": "Відкриття знімків", + "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити", + "Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0a519ce21..9b6080233 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2882,5 +2882,7 @@ "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", "Directions, drawing and your own files": "路线导航、绘制和你自己的文件", "Sea Marks": "航标", - "Ski Pistes": "滑雪道" + "Ski Pistes": "滑雪道", + "Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载", + "Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a1e92fcdb..1792ef17a 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2882,5 +2882,7 @@ "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切換地圖", - "Opening imagery": "正在打開影像" + "Opening imagery": "正在打開影像", + "Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入", + "Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。" } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 409fdf384..4583b944c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -14,7 +14,7 @@ import { keyAlternatives, summarizeCoverage, } from './uiState.js'; -import { groundUnderCamera, viewCentre } from './view.js'; +import { groundUnderCamera, metresBetween, viewCentre } from './view.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { msg, t, translateKnown } from '../../i18n/index.js'; import { @@ -33,6 +33,11 @@ const KEY_STATUS_TIMEOUT_MS = 10_000; /** Re-measures of a ground not loaded yet (Google 3D), a second apart. */ const GROUND_RETRY_LIMIT = 5; const GROUND_RETRY_MS = 1000; +/** + * An armed point stays the OPEN button's target while the view centre is + * this close to it (m); a user who moved on opens what the view shows now. + */ +const ARMED_REACH_M = 1000; /** * The Street Level layer over imagery providers (contract in registry.js). @@ -681,8 +686,13 @@ export function createStreetLevelLayer({ const armed = state.street.armed; state.street.armed = null; if (!point && aim === 'screen-centre' && armed) { - point = armed.point; - radiusM ??= armed.radiusM; + // The button promises the centre of the view: the link's spot only + // while the view still shows it. + const centre = armed.point ? screenCentreGround() : null; + if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) { + point = armed.point; + radiusM ??= armed.radiusM; + } } const radius = Number.isFinite(radiusM) ? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM)) diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index a82dc53c5..49b2819eb 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -947,6 +947,54 @@ test('the button’s “centre of the view” is the ground under the middle of assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); }); +test('OPEN after a link armed opens at its point only while the view centre is still near it', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + // About 120 m from the view centre: the link's spot is still in view. + layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + assert.deepEqual(lookups.at(-1), { + point: { lat: 38.5808, lon: -121.494 }, + radiusM: 250, + }); + // The user flew to Paris after the link armed in Sacramento: OPEN looks + // under the view centre, and the link's radius goes with its point. + layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + const press = lookups.at(-1); + assert.ok(Math.abs(press.point.lat - 38.5799) < 1e-9, 'the view centre'); + assert.ok(Math.abs(press.point.lon - -121.4937) < 1e-9); + assert.equal(press.radiusM, null, 'the default radius'); + assert.equal(layer.getUIState().street.armed, null, 'dropped'); +}); + test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => { const { createStreetLevelLayer: create } = await import('./index.js'); const scenePick = await import('../../data/scenePick.js'); @@ -1342,6 +1390,22 @@ test('providerStatus reports a keyless source on any map', async (t) => { assert.equal(mapillary.keyRejected, false); }); +test('providerStatus reports the key state as unknown until the status call answers', async (t) => { + // A freshly loaded sl= link meets its source in this state: unknown is not + // "needs its key", or the link would fail on a cold load. + const pending = fakeProvider(); + pending.requiresKeyId = 'mapillary'; + const create = pending.create; + pending.create = (context) => ({ + ...create(context), + status: () => new Promise(() => {}), + }); + const { layer } = await enabledLayer(t, [pending]); + const status = layer.providerStatus(pending.id); + assert.equal(status.configured, null); + assert.equal(status.keyRequired, false); +}); + test('providerStatus reports a refused key on any map', async (t) => { const refused = await withStreetView(t, { mapStack: 'osm' }); refused.loader.refuseKey(); diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index b1609983a..64b5d8073 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -77,8 +77,11 @@ export const SEQUENCE_IMAGE_FIELDS = /** * Images asked for in a nearest-image search (show_street_level and links * open the photo nearest a point; the graph API caps the radius at 50 m). + * The API returns the radius's images in no particular order and cuts them + * at the limit first, so this asks for its most (100): the distance sort and + * the imagery filter then choose among the spot's photos, not a few of them. */ -export const NEAREST_LIMIT = 8; +export const NEAREST_LIMIT = 100; /** Fields a nearest-image search needs: where each is, and the filter's. */ export const NEAREST_IMAGE_FIELDS = 'id,captured_at,geometry,computed_geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index ae406df32..31ec665ad 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -201,6 +201,9 @@ test('nearestImage opens the closest image that passes the filter, not the first assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); assert.equal(requests[0].options.signal, signal, 'the core can abort it'); assert.equal(requests[0].query.radius, 50); + // The API cuts at the limit before any order: a small one would sort and + // filter a handful of arbitrary photos (a 360° filter could find none). + assert.equal(requests[0].query.limit, 100, "the API's most"); assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]); const any = createMapillaryProvider({ source }).create(fakeContext()); assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano'); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 3de7902b0..c612f908b 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -1,6 +1,11 @@ /** Links that open God's Eye View at a view. */ -import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; +import { + VIEW_PROPERTIES, + createView, + lookAtPoint, + viewUrl, +} from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; import { GLOBE_PANEL_URI } from '../globePanel.js'; import { AREA_SCHEMA, resolveArea } from '../area.js'; @@ -36,6 +41,30 @@ async function followedAircraftPosition(follow, tools) { } } +/** + * Where a tilted camera looks, measured from the ground under it: a view's + * altitude is above the ellipsoid, and a city a mile up would otherwise put + * the point kilometres too far ahead. Null when the terrain cannot say. + */ +async function groundedLookAt(camera, services, signal) { + if (typeof services?.terrain?.getHeights !== 'function') return null; + if (!Number.isFinite(camera.altitude_m) || !(camera.pitch_deg > -89)) + return null; + let heights; + try { + heights = await services.terrain.getHeights( + [{ lat: camera.lat, lon: camera.lon }], + { signal }, + ); + } catch (error) { + if (signal?.aborted) throw error; + return null; + } + const ground = Number(heights?.[0]?.ellipsoid); + if (!Number.isFinite(ground)) return null; + return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground }); +} + /** * The view described by tool arguments. An area frames the camera straight * down over it; camera fields given alongside it override the framing. A @@ -91,6 +120,15 @@ export async function resolveViewArguments(args, { services, signal, tools }) { camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position }; label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`; } + // Photos for a camera with no point open where it looks, from its ground. + if ( + streetLevel && + streetLevel.lat === undefined && + streetLevel.lon === undefined + ) { + const point = await groundedLookAt(camera, services, signal); + if (point) streetLevel = { ...streetLevel, ...point }; + } let view; try { view = createView({ @@ -150,7 +188,13 @@ export const showInGodsEyeView = defineTool({ const url = viewUrl(base, view); return { summary: `Open ${label} in God's Eye View: ${url}`, - data: { url, view }, + // A panel opens a billed photo only for a result it receives fresh; + // hosts hand a panel its result again whenever they mount it. + data: { + url, + view, + ...(view.street_level ? { issued_at: Date.now() } : {}), + }, }; }, }); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index aac74a776..6b1c55f4a 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -136,6 +136,8 @@ test('links can open street-level photos at the area, on the map the source need assert.equal(params.get('sl'), 'google,38.57990,-121.49370'); assert.equal(params.get('map'), 'photoreal'); assert.deepEqual(viewFromParams(params), view); + // The panel opens a billed photo only for a result it gets fresh. + assert.ok(Math.abs(Date.now() - result.data.issued_at) < 60_000); const point = await catalog.call('show_in_gods_eye_view', { view, @@ -189,6 +191,19 @@ test('a new area moves a passed-through view’s photos, and a tilted area still lat: 48.8584, lon: 2.2945, }); + // A wide area frames the camera tens of km up, past where a camera's + // look-at point is trusted: the photos still open at the area's centre. + const wide = await catalog.call('show_in_gods_eye_view', { + area: { lat: 48.8584, lon: 2.2945, radius_km: 15 }, + camera: { pitch_deg: -30 }, + street_level: { provider: 'mapillary' }, + }); + assert.ok(wide.data.view.camera.altitude_m > 20_000); + assert.deepEqual(wide.data.view.street_level, { + provider: 'mapillary', + lat: 48.8584, + lon: 2.2945, + }); await assert.rejects( catalog.call('show_in_gods_eye_view', { area: { lat: 0, lon: 0, radius_km: 1 }, @@ -199,6 +214,61 @@ test('a new area moves a passed-through view’s photos, and a tilted area still ); }); +test('a camera with no photo point opens photos where it looks, measured from its ground', async () => { + const { lookAtPoint } = await import('../../view/index.js'); + const asked = []; + const catalog = composeCatalog({ + tools: coreTools, + services: { + app: { baseUrl: 'http://localhost:4173/' }, + terrain: { + getHeights: async (points) => { + asked.push(points); + return [{ ellipsoid: 1590 }]; + }, + }, + }, + }); + // Denver: 1,909 m above the ellipsoid is about 320 m above its streets. + const camera = { + lat: 39.7392, + lon: -104.9903, + altitude_m: 1909, + pitch_deg: -30, + heading_deg: 0, + }; + const result = await catalog.call('show_in_gods_eye_view', { + camera, + street_level: { provider: 'mapillary' }, + }); + assert.deepEqual(asked, [[{ lat: 39.7392, lon: -104.9903 }]]); + const grounded = lookAtPoint({ ...camera, altitude_m: 319 }); + const { street_level: street } = result.data.view; + assert.ok(Math.abs(street.lat - grounded.lat) < 1e-5, 'about 550 m ahead'); + assert.ok( + street.lat - camera.lat < 0.006, + 'not the 3.3 km a sea-level guess gives', + ); + // No terrain answer: the view's own estimate, as before. + const offline = composeCatalog({ + tools: coreTools, + services: { + app: { baseUrl: 'http://localhost:4173/' }, + terrain: { + getHeights: async () => { + throw new Error('offline'); + }, + }, + }, + }); + const fallback = await offline.call('show_in_gods_eye_view', { + camera, + street_level: { provider: 'mapillary' }, + }); + const guess = lookAtPoint(camera); + assert.ok(Math.abs(fallback.data.view.street_level.lat - guess.lat) < 1e-5); +}); + test('a view passed through alone keeps its street-level photos', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 2dd75cd3b..80d20731e 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -4,7 +4,7 @@ import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; -import { initMobileShell } from './mobileShell.js'; +import { initMobileShell, sheetForPanelId } from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -1098,6 +1098,7 @@ export class StyleManager extends ShellFacade { this._dataManager?.setLayerParams('street-level', params, options), setPanelCollapsed: (collapsed, options) => this.setPanelCollapsed('street-level-panel', collapsed, options), + revealPanel: () => this.revealPanel('street-level-panel'), showToast: (message) => this._showToast(message), // Lets the panel open for a user, voice or tool switch-on, not a restore. subscribeEnableRequests: (listener) => @@ -1212,6 +1213,17 @@ export class StyleManager extends ShellFacade { return this._panelChrome.setPanelCollapsed(...arguments); } + /** + * Bring a panel into view on a phone, where the rails show only as tab + * sheets: opens the sheet that holds it. Does nothing above 720px. + * @param {string} panelId - DOM id of the panel. + * @returns {void} + */ + revealPanel(panelId) { + const sheet = sheetForPanelId(panelId); + if (sheet) this._mobileShell?.setSheet(sheet); + } + /** * Toggles "clean view" mode which hides all UI panels via a CSS body class. * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle. diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 001157f7c..2610109e6 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -388,7 +388,12 @@ export class StreetLevelControls { // Other size changes reach the viewer through the ResizeObserver; opening // asks for one resize because the element may not have a size yet. const open = view.viewer.open; - if (open && !this._wasOpen) this.setCollapsed(false, { persist: false }); + if (open && !this._wasOpen) { + this.setCollapsed(false, { persist: false }); + // A phone shows the rail only as a tab sheet: open it, or the photo + // loads where nobody sees it. Embed mode lifts the photo instead. + if (!isEmbedPage()) this.actions.revealPanel?.(); + } this._wasOpen = open; // Embed mode hides the rail the panel sits in: an open photo is shown over // the globe instead, and goes back into the panel when it closes. diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 9a38c0b98..80e7c184a 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -967,6 +967,23 @@ test('outside embed mode a photo stays in the panel', () => controls.destroy(); })); +test('an opening photo brings its panel into view (a phone’s tab sheet); embed mode lifts it instead', () => + withDom(async (dom) => { + const revealed = []; + const { layer, controls } = await stubPanel(dom, uiState({ open: false }), { + revealPanel: () => revealed.push('street-level'), + }); + await layer.publish(uiState({ open: true })); + assert.deepEqual(revealed, ['street-level'], 'once, as it opens'); + await layer.publish(uiState({ open: true, renderMode: 'fill' })); + assert.equal(revealed.length, 1, 'not on every update'); + await layer.publish(uiState({ open: false })); + dom.document.body.classList.add('ui-embed'); + await layer.publish(uiState({ open: true })); + assert.equal(revealed.length, 1, 'embed mode shows it over the globe'); + controls.destroy(); + })); + test('in embed mode Esc closes the photo, and the next photo is shown over the globe again', () => withDom(async (dom) => { dom.document.body.classList.add('ui-embed'); diff --git a/src/view/index.js b/src/view/index.js index 5caecdd70..c22f2c3f6 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -96,7 +96,7 @@ const LOOK_AT_MAX_DISTANCE_M = 10_000; * further than 10 km ahead gives its own point. Longitude wraps at the * antimeridian. */ -function lookAtPoint(camera) { +export function lookAtPoint(camera) { const pitch = Number(camera.pitch_deg ?? -90); const altitude = Number(camera.altitude_m); if ( diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index e2c4cf964..c57a386a7 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -14,6 +14,7 @@ import { applicationPinia } from '../ui/vueRuntime.js'; import * as Cesium from 'cesium'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; +import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; import { hudViewportSamples, placeLookupView, @@ -3438,6 +3439,18 @@ export async function showStreetLevel( point = { lat: location.latitude, lon: location.longitude }; } + // Cockpit, clean view, recording and scene playback hide the panel the + // photo opens in: say so before anything loads unseen. + const body = globalThis.document?.body; + if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode))) + return fail( + 'view', + msg( + 'Street-level photos are hidden in the current view. Leave it first.', + ), + { location }, + ); + // The source's map, when it needs one (Street View: Google 3D only). const requiredStack = layerModule.requiredMapStack?.(provider) || null; const activeStack = styleManager?.mapStackController?.getActiveId?.() ?? null; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ec0c76e5d..035f84509 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -4582,3 +4582,89 @@ test('a partial analyst card shows one partial chip', () => { const labels = card.display.chips.map((chip) => chip.label); assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels)); }); + +test('show_street_level superseded during its geocode changes nothing', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness({ enabled: true }); + let current = true; + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async (id) => { + h.order.push(`map:${id}`); + return { ok: true }; + }, + }, + dataManager: h.dataManager, + placeSearch: { + geocode: async () => { + current = false; + return { place: { lat: 1, lng: 2, label: 'X' } }; + }, + }, + }); + const result = await runner('show_street_level', { query: 'X', provider: 'google', fly: false }, { isCurrent: () => current }); + assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm)/.test(step)), [], 'the stale turn switched nothing'); + assert.deepEqual([result.ok, result.stage, result.cancelled], [false, 'location', true]); +}); + +test('show_street_level superseded during its flight switches nothing', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness(); + let current = true; + const pending = h.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }, { isCurrent: () => current }); + await new Promise((resolve) => setImmediate(resolve)); + current = false; + h.finishFlight(); + const result = await pending; + assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], 'the stale turn switched nothing'); + assert.equal(result.cancelled, true); +}); + +test('show_street_level superseded during its map switch lights no source', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness({ enabled: true }); + let current = true; + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async (id) => { + h.order.push(`map:${id}`); + current = false; + return { ok: true, activeStack: id }; + }, + }, + dataManager: h.dataManager, + }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }, { isCurrent: () => current }); + assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'the stale turn lit nothing'); + assert.deepEqual([result.stage, result.cancelled], ['map', true]); +}); + +test('show_street_level goes ahead while a source’s key status is still unknown', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + // A freshly loaded sl= link meets the source before its status call answers. + const { runner, order } = createStreetLevelHarness({ enabled: true, configured: null }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.equal(result.ok, true); + assert.ok(order.some((step) => step.startsWith('open:mapillary'))); +}); + +test('show_street_level in a view that hides its panel stops before anything loads unseen', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + for (const mode of ['cockpit-mode', 'ui-clean-view', 'recording-mode', 'scene-playback-mode']) { + globalThis.document = { body: { classList: { contains: (name) => name === mode } } }; + try { + const { runner, order } = createStreetLevelHarness({ enabled: true }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage], [false, 'view'], mode); + assert.match(result.error, /hidden in the current view/); + assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`); + } finally { + globalThis.document = saved; + } + } +}); diff --git a/src/voice/speech.js b/src/voice/speech.js index f481d1338..90ddfdc3c 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -608,6 +608,8 @@ function showStreetLevel(result) { ? `${source} refused its key.` : `${source} needs its key.`; else if (result?.stage === 'layer') say = "Couldn't turn on Street Level."; + else if (result?.stage === 'view') + say = 'Street-level photos are hidden in this view. Leave it first.'; else if (result?.stage === 'imagery') say = result?.filter?.label ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` From 7703090d906f58fc53f4a7caa5d128fc1a62e1ab Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 14:59:47 +0000 Subject: [PATCH 019/181] fix(street-level): retire lookups and preserve embedded photos Clear cancelled lookup loading over an existing photo, release caller abort listeners on internal retirement, surface embedded errors, and permit embedded photos over the clean globe. Add seven regression cases and update runtime documentation. Preserve the published localization and other review fixes. --- changelog.d/1113-show-street-level.md | 8 ++- docs/CURRENT-STATE.md | 11 +++- src/layers/streetLevel/index.js | 6 +- src/layers/streetLevel/index.test.mjs | 80 ++++++++++++++++++++++++ src/ui/stores/streetLevel.js | 9 +++ src/ui/streetLevelControls.js | 1 + src/ui/streetLevelControls.test.mjs | 33 ++++++++++ src/ui/views/panels/StreetLevelPanel.vue | 5 +- src/voice/gevActions.js | 12 +++- src/voice/gevActions.test.mjs | 22 +++++++ 10 files changed, 175 insertions(+), 12 deletions(-) diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index 76bd35de3..dd9cdf6fd 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -11,11 +11,15 @@ God's Eye View panel only for a result issued in the last five minutes. On a phone, a photo that opens or a link that arms opens the Cameras sheet; in cockpit, clean view, recording or scene playback the action says the - panel is hidden instead of loading a photo nobody sees. Mapillary's + panel is hidden instead of loading a photo nobody sees. Embedded photos + remain available over the clean globe. Mapillary's nearest-photo search asks for 100 photos, so the nearest and the filter's pick come from the spot's photos rather than an arbitrary eight. The source the action lights stays lit like a chip click. An unavailable map stack now reports the controller's own reason instead of always asking for a Cesium ion token. When nothing is found and a 360°, flat or SINCE filter the source - honours is on, the answer names the filter (Said Turksever, @asturksever, + honours is on, the answer names the filter. Cancelled lookups clear their + loading state over an existing photo and release their caller abort listeners. + Embedded photos show loading errors inside the overlay, and the OPEN prompt + uses the recipient's selected language (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 437f32c33..6f3172089 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4342,7 +4342,8 @@ calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, recording or scene playback hides the panel, checked after the flight and -before anything switches or loads; an empty `imagery` result names a +before anything switches or loads; embed mode exempts its own clean-view +class because the photo is lifted above the globe; an empty `imagery` result names a narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. @@ -4366,9 +4367,13 @@ applies it through the action, and a plain link runs the action once restored (one shot: the rewritten hash drops `sl`). In embed mode the rail holding the panel is hidden, so an open photo moves to an overlay on the globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full -screen, Esc leaves full screen first). A caller's abort is +screen, Esc leaves full screen first). Image-loading failures render inside +the embedded overlay, since the panel's error region is hidden with the rail. +A caller's abort is honoured through the viewer's mount and load, not just the lookup, and only -the lookup that still owns the panel may change its spinner or error. A +the lookup that still owns the panel may change its spinner or error. Cancelling +a lookup clears its loading state even when an existing photo stays open; +internally retired lookups also release the caller's abort listener. A passive link's photos stand down if anyone navigated (a gesture, a search, the assistant) while the link waited behind its own marks. A plain link never loads a photo from a source that bills every load diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 4583b944c..ad24da557 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -724,7 +724,7 @@ export function createStreetLevelLayer({ if (nearestLookup !== lookup) return false; nearestLookup = null; // The caller moved on: the panel shows nothing of this lookup. - if (state.street.loading && !state.street.open) { + if (state.street.loading) { state.street.loading = false; notify(); } @@ -751,13 +751,13 @@ export function createStreetLevelLayer({ } catch (error) { if (callerSignal?.aborted) return retired(); // Overtaken: whatever replaced it owns the panel, errors included. - if (signal.aborted) return false; + if (signal.aborted) return retired(); // One provider failing (no key, offline) must not hide the others. lastError = error; continue; } if (callerSignal?.aborted) return retired(); - if (signal.aborted) return false; + if (signal.aborted) return retired(); if (!state.enabled) break; // Switched off, or its map left, while it looked: try the next. if (!imageId || !entry.on || !providerAvailable(entry)) continue; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 49b2819eb..191246d92 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { getEventListeners } from 'node:events'; import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; @@ -1666,6 +1667,85 @@ test('openNearest keeps honouring the caller after the lookup: an abort while th assert.equal(layer.getUIState().street.loading, false); }); +for (const rejectsOnAbort of [true, false]) { + test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => { + const answer = deferred(); + const provider = fakeProvider({ + nearestImage: (_point, { signal }) => { + if (rejectsOnAbort) + signal.addEventListener('abort', () => answer.reject(signal.reason), { + once: true, + }); + return answer.promise; + }, + }); + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + assert.equal( + await layer.openImage('mapillary', 'existing', { frame: false }), + true, + ); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + assert.equal(layer.getUIState().street.loading, true); + controller.abort(); + if (!rejectsOnAbort) answer.resolve('late-image'); + assert.equal(await pending, false); + const street = layer.getUIState().street; + assert.equal( + street.loading, + false, + 'the cancelled lookup owns and clears the spinner', + ); + assert.equal(street.open, true, 'the existing photo remains open'); + assert.equal(street.error, null); + assert.deepEqual( + provider.calls.open, + ['existing'], + 'no replacement photo opens', + ); + }); +} + +for (const rejectsOnAbort of [true, false]) { + test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => { + let answer; + const provider = fakeProvider({ + nearestImage: (_point, { signal }) => { + answer = deferred(); + if (rejectsOnAbort) + signal.addEventListener('abort', () => answer.reject(signal.reason), { + once: true, + }); + return answer.promise; + }, + }); + const { layer } = await enabledLayer(t, [provider]); + const lifetime = new AbortController(); + for (let i = 0; i < 3; i++) { + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: lifetime.signal }, + ); + assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1); + layer.closeViewer(); + if (!rejectsOnAbort) answer.resolve('late-image'); + assert.equal(await pending, false); + assert.equal( + getEventListeners(lifetime.signal, 'abort').length, + 0, + 'closing the lookup releases its listener on the application lifetime', + ); + } + assert.equal(lifetime.signal.aborted, false); + }); +} + test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => { const answers = []; const provider = fakeProvider({ diff --git a/src/ui/stores/streetLevel.js b/src/ui/stores/streetLevel.js index 9beab3d64..4fcb2e6d9 100644 --- a/src/ui/stores/streetLevel.js +++ b/src/ui/stores/streetLevel.js @@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { const expanded = shallowRef(null); /** False on a page with neither element fullscreen nor popovers. */ const expandAvailable = shallowRef(true); + /** Whether the photo is shown over the globe on an embedded page. */ + const embedOverlay = shallowRef(false); /** @type {{handlers: StreetLevelHandlers}|null} */ let owner = null; @@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { expandAvailable.value = Boolean(available); } + /** @param {boolean} on */ + function setEmbedOverlay(on) { + embedOverlay.value = Boolean(on); + } + /** * Stop forwarding actions to the owner holding `token`. The panel keeps * its last render, as the page did when its controls were wired by hand. @@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { view, expanded, expandAvailable, + embedOverlay, attach, publish, setExpanded, setExpandAvailable, + setEmbedOverlay, detach, act, describeSinceStop, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 2610109e6..fd2fdaee3 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -318,6 +318,7 @@ export class StreetLevelControls { const wrap = this._elements.viewerWrap; if (!wrap || !document.body) return; const lift = open === true && isEmbedPage(); + this.store.setEmbedOverlay(lift); if (lift && !this._embedHome) { this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling }; document.body.appendChild(wrap); diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 80e7c184a..d6d3927b8 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1038,3 +1038,36 @@ test('destroying the panel in embed mode puts an open photo back in the panel', assert.equal(overGlobe(dom), false); assertSameNode(dom.wrap.parentNode, home); })); + +test('an embedded photo failure is shown inside the overlay, and only there', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + const failed = uiState({ open: true }); + Object.assign(failed.street, { + imageId: null, + error: 'Image could not be opened', + }); + await layer.publish(failed); + assert.equal(overGlobe(dom), true); + const error = dom.wrap.querySelector('#sl-viewer-error'); + assert.ok(error, 'the overlay contains an error region'); + assert.equal(error.hidden, false); + assert.match(error.textContent, /Image could not be opened/); + assert.equal(dom.root.querySelector('#sl-error').hidden, true); + await layer.publish(uiState({ open: true })); + assert.equal(error.hidden, true, 'a successful retry clears the error'); + controls.destroy(); + })); + +test('outside embed mode a photo failure stays in the panel error region', () => + withDom(async (dom) => { + const failed = uiState({ open: true }); + failed.street.error = 'Image could not be opened'; + const { controls } = await stubPanel(dom, failed); + const error = dom.wrap.querySelector('#sl-viewer-error'); + assert.ok(error); + assert.equal(error.hidden, true, 'no duplicate error inside the photo'); + assert.equal(dom.root.querySelector('#sl-error').hidden, false); + controls.destroy(); + })); diff --git a/src/ui/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue index 0e3c87962..1661574bb 100644 --- a/src/ui/views/panels/StreetLevelPanel.vue +++ b/src/ui/views/panels/StreetLevelPanel.vue @@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js'; // Until it renders, the panel shows its initial markup. The viewer element // belongs to the layer and EXPAND to the controls. const store = useStreetLevelStore(); -const { view, expanded, expandAvailable } = storeToRefs(store); +const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store); const status = computed( () => @@ -142,6 +142,7 @@ const vThumb = { +
@@ -158,7 +159,7 @@ const vThumb = { {{ t('PROVIDERS') }}
- +
diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index c57a386a7..5f339673e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3440,9 +3440,17 @@ export async function showStreetLevel( } // Cockpit, clean view, recording and scene playback hide the panel the - // photo opens in: say so before anything loads unseen. + // photo opens in: say so before anything loads unseen. Embed mode uses + // clean view for its globe but lifts the photo out of the hidden rail. const body = globalThis.document?.body; - if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode))) + const embedded = body?.classList?.contains('ui-embed') === true; + if ( + EXCLUSIVE_SURFACE_CLASSES.some( + (mode) => + body?.classList?.contains(mode) && + !(embedded && mode === 'ui-clean-view'), + ) + ) return fail( 'view', msg( diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 035f84509..2211a9509 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -4668,3 +4668,25 @@ test('show_street_level in a view that hides its panel stops before anything loa } } }); + +test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + try { + for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) { + const classes = new Set(['ui-embed', 'ui-clean-view', mode]); + globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } }; + const { runner, order } = createStreetLevelHarness({ enabled: true }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + if (mode) { + assert.deepEqual([result.ok, result.stage], [false, 'view'], mode); + assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`); + } else { + assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe'); + assert.ok(order.some((step) => step.startsWith('open:mapillary'))); + } + } + } finally { + globalThis.document = saved; + } +}); From fed397a764ca736f409918d982f54e506e0f2e56 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 18:07:59 +0000 Subject: [PATCH 020/181] test(voice): raise the Realtime size budget to 56,500 for show_street_level --- src/voice/integrationContract.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index 64d1e394e..ceb8cee67 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -714,13 +714,13 @@ test('without an operator Overpass the prompt never offers place search', () => }); test('the Realtime prompt stays within its size budget', () => { - // Instructions plus tool schemas, in characters, under the same 55,500 + // Instructions plus tool schemas, in characters, under the same 56,500 // ceiling as the deixis prompt lint. Voice geometry adds resolve_area, // find_imagery and the analyst area scopes (osm_query only with an - // operator Overpass). + // operator Overpass); show_street_level adds its tool and routing rule. const size = (overpass) => realtimeInstructions(undefined, { overpass }).length + JSON.stringify(realtimeTools({ overpass })).length; - assert.ok(size(false) <= 55_500, `default prompt ${size(false)} chars`); - assert.ok(size(true) <= 55_500, `with Overpass ${size(true)} chars`); + assert.ok(size(false) <= 56_500, `default prompt ${size(false)} chars`); + assert.ok(size(true) <= 56_500, `with Overpass ${size(true)} chars`); }); From 9d27366b3ec42eccf96ccc323dab13579426d7f5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:06:57 -0700 Subject: [PATCH 021/181] fix(fires): show a 47.6-hour-old detection as 2d, not 48h formatAge and formatAgeLabel picked the hour unit on the raw value and rounded afterwards, so a detection between 47.5 and 48 hours old read 48h, while one exactly 48 hours old read 2d. Round to whole hours first, then choose the unit. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 1 + src/layers/firms/model.js | 8 +++++--- src/layers/firms/model.test.mjs | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 src/layers/firms/model.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..71aa36964 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2221,6 +2221,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 9e3cf7f95..7dcf255c8 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -526,7 +526,8 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return '<1h'; - if (hours < 48) return `${Math.round(hours)}h`; + const wholeHours = Math.round(hours); + if (wholeHours < 48) return `${wholeHours}h`; return `${Math.round(hours / 24)}d`; } @@ -561,8 +562,9 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return t('<1h', { context: 'age' }); - if (hours < 48) - return t('{count}h', { context: 'age', count: Math.round(hours) }); + const wholeHours = Math.round(hours); + if (wholeHours < 48) + return t('{count}h', { context: 'age', count: wholeHours }); return t('{count}d', { context: 'age', count: Math.round(hours / 24) }); } diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs new file mode 100644 index 000000000..7ff12cf3f --- /dev/null +++ b/src/layers/firms/model.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getLocale, setLocale } from '../../i18n/index.js'; +import { createModel } from './model.js'; + +const HOUR_MS = 3600000; + +test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => { + const previousLocale = getLocale(); + const { formatAge, formatAgeLabel } = createModel({ + services: { anchors: {} }, + }); + try { + setLocale('en'); + assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.6 * HOUR_MS), '2d'); + assert.equal(formatAge(48 * HOUR_MS), '2d'); + assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); + assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); + } finally { + setLocale(previousLocale); + } +}); From a4bd028bd2626d71d7c237f8bc675b36de5c3f3f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:47:41 -0800 Subject: [PATCH 022/181] changelog: FIRMS age rounding (#1531) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1531-firms-age-rounding.md diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md new file mode 100644 index 000000000..e8cc12c4b --- /dev/null +++ b/changelog.d/1531-firms-age-rounding.md @@ -0,0 +1,3 @@ +- FIRMS fire ages round to whole hours before choosing a unit, so a detection + 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" + (@quanticsoul4772, #1531). From 84c1fef8d34dfd4dd556b6dca6bda9e3d2cf6b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:15:34 -0300 Subject: [PATCH 023/181] fix(firms): stop requesting the retired Suomi NPP source NASA stopped delivering Suomi NPP data on 2026-11-02 (FIRMS news page). With a live MAP_KEY on 2026-11-04, VIIRS_SNPP_NRT came back ok with zero rows while NOAA-20, NOAA-21 and MODIS returned 57k, 49k and 11k, so the source only spent one transaction per refresh. The proxy now requests NOAA-20, NOAA-21 and MODIS NRT. The "N" satellite label stays, so cached or archived Suomi NPP rows still read SNPP. Tests count sources from the list instead of assuming four, and a regression test keeps the retired source out of the production list. Refs #849 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 7 ++++--- docs/CURRENT-STATE.md | 5 +++++ server/providers/firms.js | 14 ++++++-------- src/data/firmsProxy.test.mjs | 18 ++++++++++++++---- src/tooling/environmentProviders.test.mjs | 4 ++-- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index cac0b5ab7..efee6b9ff 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -273,11 +273,12 @@ Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons > We acknowledge the use of data and/or imagery from NASA's Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA's Earth Observing System Data and Information System (EOSDIS). FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the -`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21, -Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY +`/api/firms` server-side proxy merges the two VIIRS NRT sources (NOAA-20, NOAA-21) +and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. -The former bundled 2026-05-25 snapshot was removed 2026-07-16. +The former bundled 2026-05-25 snapshot was removed 2026-07-16. Suomi NPP (`VIIRS_SNPP_NRT`) +is no longer requested: NASA stopped delivering its data on 2026-11-02. ### Natural Earth physical regions (`natural_earth/`) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e868dfc95..102efb997 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2325,6 +2325,11 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +The FIRMS proxy requests VIIRS NOAA-20, VIIRS NOAA-21 and MODIS NRT (Terra and +Aqua). It no longer requests VIIRS Suomi NPP: NASA stopped delivering Suomi NPP +data on 2026-11-02, and the source returned no rows while still costing one +MAP_KEY transaction per refresh. + The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times diff --git a/server/providers/firms.js b/server/providers/firms.js index 20a2f4bab..51ea4fc56 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -131,8 +131,8 @@ export async function fetchFirms( * NASA FIRMS live active-fire proxy with a memory + disk cache. * Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2 * - * Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent - * satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, + * Merges two VIIRS NRT sources (NOAA-20, NOAA-21 — independent satellites, no + * cross-source dedup) plus MODIS_NRT (combined Terra+Aqua, * ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy) * with `days=2` (`days=1` means "current UTC day", nearly empty just after * 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS @@ -141,6 +141,9 @@ export async function fetchFirms( * fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream * fetch across dev-server restarts. Pattern mirrors celestrakProxy. * + * VIIRS_SNPP_NRT is not requested: NASA stopped delivering Suomi NPP data on + * 2026-11-02, so that source only spent a transaction per refresh (#849). + * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} @@ -153,12 +156,7 @@ export async function fetchFirms( export function firmsProxy() { const TTL_MS = 30 * 60_000; const STATUS_TTL_MS = 5 * 60_000; - const SOURCES = [ - 'VIIRS_NOAA20_NRT', - 'VIIRS_NOAA21_NRT', - 'VIIRS_SNPP_NRT', - 'MODIS_NRT', - ]; + const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; /** * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 14668c825..135002ca7 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT']; +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -63,7 +63,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => { if (source === SOURCES[1]) throw new Error('upstream unavailable'); return [recent]; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 1 ? 0 : 1, ok: index !== 1, }))); @@ -77,7 +77,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy // Fault injection for aggregation; ordinary parsed CSV returns an array. return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; })('fixture'); - assert.equal(result.fires.length, 3); + assert.equal(result.fires.length, SOURCES.length - 1); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ source, count: index === 0 ? 0 : 1, ok: index !== 0, }))); @@ -107,7 +107,7 @@ function wrappedAddressSelectionFailure() { { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner }); return new TypeError('fetch failed', { cause }); } -const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2'; +const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_NOAA20_NRT/world/2'; test('FIRMS failures log the socket cause, never the keyed URL', () => { const described = describeFetchError(addressSelectionFailure()); @@ -307,3 +307,13 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => assert.ok(calls[0][2] instanceof AbortSignal); assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline'); }); + +test('FIRMS requests only the satellites NASA still delivers', () => { + const listed = config.match(/const SOURCES = \[([^\]]*)\];/); + assert.ok(listed, 'FIRMS source list exists'); + const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); + assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); + // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it + // returns no rows and still spends a MAP_KEY transaction on every refresh. + assert.ok(!production.includes('VIIRS_SNPP_NRT')); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index da3cfc335..feca9432d 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.equal(json(await request()).count, 130001); - assert.equal(calls, 4); + assert.equal(calls, first.sources.length); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, From b342e405d7514505861ab26244f38496a59350cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:20:09 -0300 Subject: [PATCH 024/181] docs(changelog): add fragment for #1552 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1552-firms-drop-snpp.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1552-firms-drop-snpp.md diff --git a/changelog.d/1552-firms-drop-snpp.md b/changelog.d/1552-firms-drop-snpp.md new file mode 100644 index 000000000..fcb375bda --- /dev/null +++ b/changelog.d/1552-firms-drop-snpp.md @@ -0,0 +1,4 @@ +- Stop requesting Suomi NPP in Active Fires. NASA stopped delivering its data + on November 2, 2026, so `VIIRS_SNPP_NRT` returned no rows while still costing + a MAP_KEY transaction on every refresh; the layer now merges VIIRS NOAA-20, + VIIRS NOAA-21 and MODIS (IMBrandao, #1552). From b37025414d90d6b696dcecd252c222972c00c557 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 02:57:27 -0300 Subject: [PATCH 025/181] test(firms): pin the refresh call count and read sources from production Review follow-up for #1552: - environmentProviders: assert exactly 3 upstream calls per refresh again, so a returning fourth source would fail the test. - firmsProxy: build the test's SOURCES from server/providers/firms.js, so there is one list instead of a mirrored copy. - firmsProxy: drop the unused fourth count entry. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsProxy.test.mjs | 13 ++++++------- src/tooling/environmentProviders.test.mjs | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 135002ca7..dc9ed8144 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -21,7 +21,10 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists'); const end = config.indexOf('\n }', start); assert.notEqual(end, -1, 'FIRMS refresh function closes'); const refreshSource = config.slice(start, end + 4); -const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'MODIS_NRT']; +// Read the source list from production so these tests share one source of truth. +const listed = config.match(/const SOURCES = \[([^\]]*)\];/); +assert.ok(listed, 'FIRMS source list exists'); +const SOURCES = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); const NOW = Date.UTC(2026, 8, 11, 12); const recent = { acqDate: '2026-09-11', acqTime: '1100' }; @@ -54,7 +57,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () = assert.equal(result.fires[199_999], large.at(-1)); assert.equal(result.fires.at(-1), last); assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ - source, count: [200_000, 1, 0, 0][index], ok: true, + source, count: [200_000, 1, 0][index], ok: true, }))); }); @@ -309,11 +312,7 @@ test('every FIRMS upstream request goes through the IPv4 fallback', async () => }); test('FIRMS requests only the satellites NASA still delivers', () => { - const listed = config.match(/const SOURCES = \[([^\]]*)\];/); - assert.ok(listed, 'FIRMS source list exists'); - const production = [...listed[1].matchAll(/'([A-Z0-9_]+)'/g)].map((m) => m[1]); - assert.deepEqual(production, SOURCES, 'the test mirrors the production source list'); // NASA stopped delivering Suomi NPP data on 2026-11-02 (#849): requesting it // returns no rows and still spends a MAP_KEY transaction on every refresh. - assert.ok(!production.includes('VIIRS_SNPP_NRT')); + assert.ok(!SOURCES.includes('VIIRS_SNPP_NRT')); }); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index feca9432d..22eb39ad3 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -313,9 +313,9 @@ test('FIRMS retains a large successful source during partial failure and filters const first = json(await request()); assert.equal(first.count, 130001); assert.equal(first.sources.filter((s) => s.ok).length, 1); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.equal(json(await request()).count, 130001); - assert.equal(calls, first.sources.length); + assert.equal(calls, 3); assert.deepEqual(json(await request('/status')).transactions, { used: 3, limit: 5000, 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 026/181] 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 027/181] 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 028/181] 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 029/181] 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 652f3c8ea110f3b583e9adfd542617b06808f39b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:53:17 +0400 Subject: [PATCH 030/181] Hide a vessel's detection callout while its own card paints With Vessels and Detection on, a vessel with an ambient card on the map also got its detection callout next to the bracket. The callout lane paints beneath the cards, so its text showed through the card's plate and covered the speed and course. The callout now gives way only to a card the shared overlay actually paints in the same frame. The overlay paints part of the published cards (shared budget, collisions, UI chrome), so a vessel whose card was dropped keeps its callout. The detection lane paints before the cards publish their paint rects, so getOverlayPaintRect has nothing for the current frame there. The new isOverlayEntryQueued answers from the paint queue that the overlay solves before painting. Vessel detection objects name their card in cardSourceId/cardEntryId. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 39 +++++++++++++++++++ src/data/detection.js | 24 +++++++++++- src/data/detectionHost.test.mjs | 62 ++++++++++++++++++++++++++++++ src/layers/vessels/queries.js | 7 +++- src/overlays/worldOverlay.js | 26 +++++++++++++ src/overlays/worldOverlay.test.mjs | 38 ++++++++++++++++++ 7 files changed, 194 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f1244947d..370edcd7b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5322,7 +5322,7 @@ silently demoting every later lookup for the session. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. -- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. +- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 8e12bff94..edb3505f2 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1786,3 +1786,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked', assert.equal(result.activeCount, 1); for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); }); + +test('detection objects name the vessel card their callout gives way to', () => { + const vessel = (mmsi, name) => ({ + name, + mmsi, + type: 'Cargo', + destination: '', + speed: 9.5, + course: 80, + heading: 80, + lastPositionUtc: '', + missedRefreshes: 0, + position: POS, + billboard: null, + }); + const keyed = vessel('200', 'GOLDEN BEAR'); + const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 }; + _setVesselStateForTest({ + viewer: {}, + records: [keyed, unkeyed], + billboardCollection: { show: true, remove() {} }, + }); + try { + const objects = Object.fromEntries( + aisLiveVesselsLayer + .getDetectableObjects() + .map((object) => [object.id, object]), + ); + assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels'); + assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200'); + assert.equal( + objects['NO MMSI'].cardEntryId, + 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', + 'the same identity the vessel card is published under', + ); + } finally { + _setVesselStateForTest({ enabled: false }); + } +}); diff --git a/src/data/detection.js b/src/data/detection.js index 36ed6372e..8ea124649 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -21,7 +21,10 @@ import { getKeyholeFadeTuning, keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; -import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js'; +import { + isOverlayEntryQueued, + registerWorldOverlayPaintLane, +} from '../overlays/worldOverlay.js'; import { DETECTION_STYLE, DETECTION_THEME_MAP, @@ -576,6 +579,7 @@ export function getDetectionDiagnostics() { // sprites/strokes. Nothing is copied on the production paint path. result.calloutRects = _calloutPool .slice(0, _calloutCount) + .filter((row) => !row.yielded) .map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha })); return result; } @@ -1077,6 +1081,9 @@ function _stashCallout(entry, acquireFade, keyhole) { font: FONT, microFont: MICRO_FONT, alpha: 1, + cardSourceId: '', + cardEntryId: '', + yielded: false, }; _calloutPool[_calloutCount] = row; } @@ -1103,6 +1110,9 @@ function _stashCallout(entry, acquireFade, keyhole) { row.font = FONT; row.microFont = MICRO_FONT; row.alpha = alpha; + row.cardSourceId = candidate.cardSourceId; + row.cardEntryId = candidate.cardEntryId; + row.yielded = false; _calloutCount++; } @@ -1134,7 +1144,15 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - paintDetectionCallout(ctx, row, row.alpha); + // The contact's own card (an ambient vessel card) already names it, and + // this callout would show through the card's plate. Checked every frame + // against the cards queued for THIS frame: the host paints only part of + // the published cards, and a contact whose card did not paint keeps its + // callout. + row.yielded = + row.cardEntryId !== '' && + isOverlayEntryQueued(row.cardSourceId, row.cardEntryId); + if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha); } ctx.globalAlpha = 1; } @@ -1211,6 +1229,8 @@ function _materializeCandidate( primary, micro, hasMicro: card.hasMicro, + cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '', + cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '', }; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index f8858d602..7bcb9300e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -19,9 +19,11 @@ import { suspendDetection, } from './detection.js'; import { + clearOverlaySource, destroyWorldOverlay, initWorldOverlay, setOverlayEntries, + setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { @@ -579,6 +581,66 @@ test('every style paints its OWN plate token through the production callout path } }); +test('a callout gives way to its contact\'s own card only while that card paints', () => { + // A vessel's ambient card already shows its name, speed and course, and the + // callout drawn beneath it shows through the card's plate. The host paints + // only part of the published cards (shared budget, collisions, UI chrome), + // so a contact whose card was published but not painted keeps its callout. + const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); + try { + const ships = [ + { x: -0.3, sourceId: 'carded', id: 'SHIP-CARDED', card: 'vessel:carded' }, + { x: 0.3, sourceId: 'dropped', id: 'SHIP-DROPPED', card: 'vessel:dropped' }, + ].map(({ x, sourceId, id, card }) => ({ + position: new Cesium.Cartesian3(x, 0, 6_356_752), + sourceId, + id, + metric: '6 kn', + type: 'SEA', + cardSourceId: card === 'vessel:carded' ? 'vessel-cards' : 'dropped-cards', + cardEntryId: card, + })); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {}); + const card = (id, title, x) => ({ + id, + position: new Cesium.Cartesian3(x, 0, 6_356_752), + variant: 'label', + title, + selected: true, + protected: true, + horizonCull: false, + edgeFade: 'none', + }); + setOverlayEntries('vessel-cards', [card('vessel:carded', 'CARD-CARDED', -0.3)]); + // Published, but the host does not paint it this frame. + setOverlayEntries('dropped-cards', [card('vessel:dropped', 'CARD-DROPPED', 0.3)]); + setOverlaySourceVisible('dropped-cards', false); + setMode('DENSE'); + settleFrame(env); + env.ctx.calls.length = 0; + settleFrame(env); + + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + assert.ok(painted('CARD-CARDED'), 'the fixture card reached the frame'); + assert.ok(!painted('CARD-DROPPED'), 'the hidden source paints no card'); + assert.ok(!painted('SHIP-CARDED'), 'no callout under the painted card'); + assert.ok(painted('SHIP-DROPPED'), 'the contact whose card did not paint keeps its callout'); + assert.equal(getDetectionDiagnostics().calloutRects.length, 1, + 'only the callout that painted is reported'); + + // The card leaves the host: the callout comes back on the next frame. + clearOverlaySource('vessel-cards'); + env.ctx.calls.length = 0; + settleFrame(env); + assert.ok(painted('SHIP-CARDED'), 'the callout returns once its card stops painting'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + test('space-tier callouts take the heavier plate while air contacts keep the light one', () => { // Satellites sit over the high-albedo lit Earth disc far more often than // aircraft do, so the space tier carries the heavier backing. Losing the diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..7b2b66825 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -21,6 +21,7 @@ import { REFRESH_MS, FOCUS_EVIDENCE_DEV, } from './policy.js'; +import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; export function createQueries({ vesselState, @@ -469,7 +470,7 @@ export function createQueries({ * @param {Object} [options={}] - Options from the detection system. * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.seed] - Seed offset for stride sampling. - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>} */ getDetectableObjects(options = {}) { if ( @@ -504,6 +505,10 @@ export function createQueries({ id: record.name || record.mmsi || 'VESSEL', type: 'SEA', skipLabel: record === selected, + // The vessel's ambient card: while the overlay paints it, detection + // draws no callout under it. + cardSourceId: VESSEL_OVERLAY_SOURCE_ID, + cardEntryId: components.cards.vesselOverlayEntryId(record), klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 7b0b9d5fd..16699c548 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1065,6 +1065,22 @@ export function getOverlayPaintRect(sourceId, entryId) { return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null; } +/** + * Whether an entry is queued to paint in the frame being painted. Custom lanes + * run before the host's own entries, so `getOverlayPaintRect` has nothing for + * this frame yet while they paint; this answers from the solved paint queue. + * @param {string} sourceId + * @param {string} entryId + * @returns {boolean} + */ +export function isOverlayEntryQueued(sourceId, entryId) { + if (_destroyed) return false; + const record = _records.get(entryKey(String(sourceId), String(entryId))); + return ( + !!record && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001 + ); +} + /** * Resolve the topmost interactive world-overlay entry at CSS-pixel coords. * @param {number} x @@ -1737,6 +1753,8 @@ function getProjectionRecord(entry) { contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, + queuedStamp: 0, + queuedAlpha: 0, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2254,6 +2272,14 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.zIndex = record.entry.zIndex; item.key = record.key; _paintQueue[_paintCount++] = item; + // Read by `isOverlayEntryQueued` while the custom lanes paint, before this + // item publishes its paint rect. Keyhole and sonar fades are left out. + record.queuedStamp = _frameStamp; + record.queuedAlpha = + record.sourceAlpha * + temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha; } function sourceCanPaint(sourceId) { diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index f2e608b78..a0b5160b6 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -24,6 +24,7 @@ import { activateWorldOverlayAction, initWorldOverlay, subscribeWorldOverlayActions, + isOverlayEntryQueued, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, @@ -1213,6 +1214,43 @@ test('custom detection lane receives the shared host frame and paints below ordi env.cleanup(); }); +test('a detection lane sees which cards this frame will paint before they publish paint rects', () => { + // The detection lane paints first, so a card's paint rectangle for this + // frame does not exist yet when it runs. The queued check is what lets a + // contact's callout give way to that contact's own card in the same frame. + const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); + initWorldOverlay(env.viewer); + const seen = []; + registerWorldOverlayPaintLane('detection', () => { + seen.push({ + shown: isOverlayEntryQueued('cards', 'SHOWN'), + hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'), + unknown: isOverlayEntryQueued('cards', 'NOT-PUBLISHED'), + rect: getOverlayPaintRect('cards', 'SHOWN'), + }); + }, { id: 'detection', active: true, target: 'detection' }); + setOverlayEntries('cards', [selectedEntry('SHOWN', { + position: positionAtScreen(200, 150), + })]); + setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', { + position: positionAtScreen(100, 100), + })]); + setOverlaySourceVisible('hidden-cards', false); + + env.postRender.raise(); + assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null }, + 'the card is queued while its paint rect is still unpublished'); + assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane'); + assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null); + + clearOverlaySource('cards'); + env.postRender.raise(); + assert.equal(seen[1].shown, false, 'a card cleared since the last frame is no longer queued'); + destroyWorldOverlay(); + assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false); + env.cleanup(); +}); + test('custom-lane painter state is bracketed by host save/restore', () => { // A painter that leaks a clip or alpha must not corrupt later frames: the // host wraps every custom-lane painter in save/try/finally-restore. Pin the From 092e7e71a8cabcaa2ed1274f5f217a0e971e1bec Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 12:54:11 +0400 Subject: [PATCH 031/181] Add the changelog entry for #1577 --- changelog.d/1577-vessel-callout-under-card.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1577-vessel-callout-under-card.md diff --git a/changelog.d/1577-vessel-callout-under-card.md b/changelog.d/1577-vessel-callout-under-card.md new file mode 100644 index 000000000..579d805b9 --- /dev/null +++ b/changelog.d/1577-vessel-callout-under-card.md @@ -0,0 +1,3 @@ +- A vessel whose ambient card is on the map no longer gets a Detection callout + showing through the card and covering its speed and course. A vessel whose + card the overlay did not paint keeps its callout (German, @gekh, #1577). From ee3466dff93152204238bd7f83bd456d850bef00 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:14:00 +0400 Subject: [PATCH 032/181] Keep the queued-card check off the per-frame allocation path Writing a fractional alpha onto the overlay record for every paint item boxed a heap number per item per frame. The allocation gate caught it: the generic 60-entry workload went from 2,838 to 12,614 B/frame. The record now keeps only the frame stamp and a pointer to its paint item, and isOverlayEntryQueued multiplies the alphas when it is asked. The gate is back to main's numbers on Node 24. --- src/overlays/worldOverlay.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 16699c548..726e766ee 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1076,8 +1076,14 @@ export function getOverlayPaintRect(sourceId, entryId) { export function isOverlayEntryQueued(sourceId, entryId) { if (_destroyed) return false; const record = _records.get(entryKey(String(sourceId), String(entryId))); + if (!record || record.queuedStamp !== _frameStamp) return false; + // Same channels as the paint, minus the per-frame keyhole and sonar fades. return ( - !!record && record.queuedStamp === _frameStamp && record.queuedAlpha > 0.001 + record.sourceAlpha * + record.queuedItem.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha > + 0.001 ); } @@ -1754,7 +1760,7 @@ function getProjectionRecord(entry) { altitudeAlpha: 1, sourceAlpha: 1, queuedStamp: 0, - queuedAlpha: 0, + queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2273,13 +2279,10 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.key = record.key; _paintQueue[_paintCount++] = item; // Read by `isOverlayEntryQueued` while the custom lanes paint, before this - // item publishes its paint rect. Keyhole and sonar fades are left out. + // item publishes its paint rect. Only a stamp and a pointer: storing the + // item's alpha here would box a double per paint item per frame. record.queuedStamp = _frameStamp; - record.queuedAlpha = - record.sourceAlpha * - temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha; + record.queuedItem = item; } function sourceCanPaint(sourceId) { From ceff3eb6c97e7995938488417a66ed3e1b5af0ac Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:40 -0800 Subject: [PATCH 033/181] test(fires): pin the 47.5-hour flip to days and name where the age shows Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 8 +++++--- docs/CURRENT-STATE.md | 2 +- src/layers/firms/model.test.mjs | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md index e8cc12c4b..cd9bcfc5d 100644 --- a/changelog.d/1531-firms-age-rounding.md +++ b/changelog.d/1531-firms-age-rounding.md @@ -1,3 +1,5 @@ -- FIRMS fire ages round to whole hours before choosing a unit, so a detection - 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" - (@quanticsoul4772, #1531). +- FIRMS fire ages round to whole hours before choosing a unit, so an age of + 47.5–48 hours reads "2d", as exactly 48 hours does, not "48h". Detections + are filtered to the trailing 24 hours, so this shows mostly in the + `STALE · cached {age}` stats line after about two days without a successful + refresh (@quanticsoul4772, #1531). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 726201138..1e1230833 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2382,7 +2382,7 @@ reporting are unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. -FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs index 7ff12cf3f..a0df2eb2c 100644 --- a/src/layers/firms/model.test.mjs +++ b/src/layers/firms/model.test.mjs @@ -13,9 +13,11 @@ test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => try { setLocale('en'); assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.5 * HOUR_MS), '2d'); assert.equal(formatAge(47.6 * HOUR_MS), '2d'); assert.equal(formatAge(48 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); } finally { 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 034/181] 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 da00b096d2bae42b28a5e34a6c7aed5543d0291b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:21:02 +0400 Subject: [PATCH 035/181] Fade a vessel's callout by its card's painted opacity The callout now asks for the opacity its own card paints with this frame, keyhole fade included, and fades out as the card fades in. A card faded out at the keyhole rim leaves the callout readable, and a card fading out after leaving its source still hides it. The vessel card id is cached on the record, and the allocation gate covers the lookup. --- docs/CURRENT-STATE.md | 2 +- src/data/aisLiveVessels.test.mjs | 1 + src/data/detection.js | 33 ++-- src/data/detectionHost.test.mjs | 174 ++++++++++++++++++ src/layers/vessels/cards.js | 8 + src/layers/vessels/records.js | 3 + src/overlays/worldOverlay.js | 91 +++++---- src/overlays/worldOverlay.test.mjs | 16 +- .../worldOverlayAllocation.worker.mjs | 3 + 9 files changed, 274 insertions(+), 57 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 370edcd7b..26cf5af18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5322,7 +5322,7 @@ silently demoting every later lookup for the session. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. -- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel whose ambient card the shared host paints in the current frame draws no detection callout, because the callout would show through the card's plate, while a vessel whose card was published but not painted (shared budget, collisions, UI chrome) keeps its callout — its detectable object names the card in `cardSourceId`/`cardEntryId`, and the callout lane asks `isOverlayEntryQueued` in `src/overlays/worldOverlay.js`, which answers from the solved paint queue because the detection lane paints before the cards publish their paint rects), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. +- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's detection callout fades out while its own ambient card paints in the same frame, judged by the card's painted opacity including the keyhole fade, so a card dropped by budget, collisions or UI chrome, or faded out at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. - **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index edb3505f2..e1c2fcc5a 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1821,6 +1821,7 @@ test('detection objects name the vessel card their callout gives way to', () => 'vessel:unkeyed:NO MMSI:37.80000:-122.40000', 'the same identity the vessel card is published under', ); + assert.equal(keyed.overlayEntryId, 'vessel:200', 'cached on the record for later frames'); } finally { _setVesselStateForTest({ enabled: false }); } diff --git a/src/data/detection.js b/src/data/detection.js index 8ea124649..f4d1ddf3b 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -22,7 +22,7 @@ import { keyholeLabelAlphaFromGeometry, } from '../celestialRing.js'; import { - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, registerWorldOverlayPaintLane, } from '../overlays/worldOverlay.js'; import { @@ -110,6 +110,11 @@ const THEME_MAP = DETECTION_THEME_MAP; const SPARSE_ZONE_FRACTION = 0.5; /** Solve cadence for membership/collision; accepted identities still reproject every frame. */ const LABEL_SOLVE_INTERVAL_MS = 125; +/** + * A contact's own card at this opacity or more hides its callout; below it the + * callout fades in, so a card fading in or out never snaps the callout. + */ +const CARD_ALPHA_HIDING_CALLOUT = 0.5; /** Alpha bands retain bracket batching while approximating a continuous radial fade. */ const BRACKET_ALPHA_STEPS = 4; /** Finer bands for Global Context echo brackets, whose fade is animated. */ @@ -579,8 +584,8 @@ export function getDetectionDiagnostics() { // sprites/strokes. Nothing is copied on the production paint path. result.calloutRects = _calloutPool .slice(0, _calloutCount) - .filter((row) => !row.yielded) - .map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha })); + .filter((row) => row.paintAlpha > 0.001) + .map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha })); return result; } @@ -1083,7 +1088,7 @@ function _stashCallout(entry, acquireFade, keyhole) { alpha: 1, cardSourceId: '', cardEntryId: '', - yielded: false, + paintAlpha: 1, }; _calloutPool[_calloutCount] = row; } @@ -1112,7 +1117,7 @@ function _stashCallout(entry, acquireFade, keyhole) { row.alpha = alpha; row.cardSourceId = candidate.cardSourceId; row.cardEntryId = candidate.cardEntryId; - row.yielded = false; + row.paintAlpha = alpha; _calloutCount++; } @@ -1144,15 +1149,15 @@ function _paintCalloutLane(frame) { ctx.textBaseline = 'alphabetic'; for (let i = 0; i < _calloutCount; i++) { const row = _calloutPool[i]; - // The contact's own card (an ambient vessel card) already names it, and - // this callout would show through the card's plate. Checked every frame - // against the cards queued for THIS frame: the host paints only part of - // the published cards, and a contact whose card did not paint keeps its - // callout. - row.yielded = - row.cardEntryId !== '' && - isOverlayEntryQueued(row.cardSourceId, row.cardEntryId); - if (!row.yielded) paintDetectionCallout(ctx, row, row.alpha); + // Under the contact's own card (an ambient vessel card) the callout would + // show through the plate. Only a card painting this frame counts. + const cardAlpha = + row.cardEntryId === '' + ? 0 + : getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId); + row.paintAlpha = + row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT); + if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha); } ctx.globalAlpha = 1; } diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 7bcb9300e..4c1c6dddc 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -21,10 +21,17 @@ import { import { clearOverlaySource, destroyWorldOverlay, + getOverlayPaintRect, initWorldOverlay, setOverlayEntries, setOverlaySourceVisible, } from '../overlays/worldOverlay.js'; +import { applyVesselOverlayPolicy } from './vesselLabels.js'; +import { + getKeyholeFadeTuning, + getKeyholeGeometry, + keyholeLabelAlphaFromGeometry, +} from '../celestialRing.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; import { clearAwarenessEchoField, @@ -641,6 +648,173 @@ test('a callout gives way to its contact\'s own card only while that card paints } }); +/** A vessel detection contact that names its ambient card, like the AIS layer does. */ +function shipWithCard(mmsi, position) { + return { + position, + sourceId: mmsi, + id: `SHIP-${mmsi}`, + metric: '6 kn', + type: 'SEA', + cardSourceId: 'ais-live-vessels', + cardEntryId: `vessel:${mmsi}`, + }; +} + +/** The production ambient vessel card (not protected: it goes through the arbiter). */ +function ambientVesselCard(mmsi, position) { + return applyVesselOverlayPolicy({ + id: `vessel:${mmsi}`, + position, + title: `CARD-${mmsi}`, + details: ['CARGO · 6KT · 80°'], + gapPx: 10, + }); +} + +test('a callout stays readable when its own card is faded out at the keyhole rim', () => { + // Default keyhole tuning: 7% feather, 1% outside opacity. + assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 }); + const width = 1600; + const height = 900; + const keyhole = getKeyholeGeometry(width, height); + const env = installEnvironment({ width, height, dpr: 1 }); + // Just outside the keyhole's left rim: the card sits above or below the + // anchor (outside, 1% alpha), the callout to its right (inside the feather). + const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752); + const ship = shipWithCard('200', position); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + const [alone] = getDetectionDiagnostics().calloutRects; + assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible'); + + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]); + for (let i = 0; i < 4; i++) settleFrame(env); + const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200'); + assert.ok(rect, 'the host paints the card (at the outside opacity)'); + const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole); + assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`); + + const callouts = getDetectionDiagnostics().calloutRects; + assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out'); + assert.ok(callouts[0].alpha >= 0.4, 'and stays readable'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a callout stays hidden while its card fades out after leaving the published cohort', () => { + const env = installEnvironment({ width: 1600, height: 900, dpr: 1 }); + const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752); + const ship = shipWithCard('200', at(0.1, 0.1)); + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + const frame = (ms) => { + env.ctx.calls.length = 0; + env.advance(ms); + env.postRender.raise(); + }; + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]); + for (let i = 0; i < 6; i++) frame(250); + frame(16); + assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only'); + + // The vessel layer republishes its screen-grid cohort after a camera move: + // this vessel's card is replaced by another one, the vessel stays detectable. + setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]); + frame(16); + assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out'); + assert.ok(painted('CARD-200'), 'and its text still paints'); + assert.ok(!painted('SHIP-200'), 'no full callout under the fading card'); + + for (let i = 0; i < 4; i++) frame(250); + assert.ok(!painted('CARD-200'), 'the card has faded out'); + assert.ok(painted('SHIP-200'), 'and the callout is back'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a vessel whose ambient card loses the collision keeps its callout', () => { + const env = installEnvironment({ width: 900, height: 600, dpr: 1 }); + // Four vessels a few pixels apart: their cards collide (a card goes only + // above or below its anchor), so the arbiter paints only some of them. + const ships = [0, 1, 2, 3].map((i) => + shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)), + ); + const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {}); + setOverlayEntries( + 'ais-live-vessels', + ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)), + ); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + env.ctx.calls.length = 0; + settleFrame(env); + + const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId)); + assert.ok( + winners.length >= 1 && winners.length < ships.length, + `the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`, + ); + for (const ship of ships) { + const carded = winners.includes(ship); + assert.equal( + painted(ship.id), + !carded, + carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`, + ); + } + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + +test('a callout fades out while its card fades in instead of vanishing at once', () => { + const env = installEnvironment({ width: 1600, height: 900, dpr: 1 }); + const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752)); + try { + initWorldOverlay(env.viewer); + initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {}); + setMode('DENSE'); + for (let i = 0; i < 4; i++) settleFrame(env); + const [alone] = getDetectionDiagnostics().calloutRects; + assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible'); + + setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]); + const alphas = []; + for (let i = 0; i < 30; i++) { + env.advance(16); + env.postRender.raise(); + alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0); + } + assert.ok( + alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1), + `the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`, + ); + assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in'); + } finally { + destroyDetection(); + destroyWorldOverlay(); + env.cleanup(); + } +}); + test('space-tier callouts take the heavier plate while air contacts keep the light one', () => { // Satellites sit over the high-albedo lit Earth disc far more often than // aircraft do, so the space tier carries the heavier backing. Losing the diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..2563bf420 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -117,6 +117,14 @@ export function createCards({ /** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { + // Cached on the record: detection asks for it every frame for every vessel. + if (record?.overlayEntryId) return record.overlayEntryId; + const id = composeVesselOverlayEntryId(record); + if (record) record.overlayEntryId = id; + return id; + } + + function composeVesselOverlayEntryId(record) { const mmsi = String(record?.mmsi || '').trim(); if (mmsi) return `vessel:${mmsi}`; const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..aeb2f39bf 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -23,6 +23,9 @@ export function normalizeVessel(row) { lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, + // Filled on first use by the cards' `vesselOverlayEntryId`. The id reads + // only the MMSI, or for an unkeyed row fields it never updates. + overlayEntryId: null, }; } diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 726e766ee..aabcc7be0 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -258,6 +258,10 @@ const _paintQueue = []; const _paintItemPool = []; const _paintRectPool = []; const _paintRectByKey = new Map(); +/** @type {Map>} This frame's paint items by source and entry id. */ +const _queuedItemBySource = new Map(); +/** Index rows that stop painting are dropped this many frames apart. */ +const QUEUED_ITEM_PRUNE_FRAMES = 600; const _hitRects = []; const _paintedBySource = Object.create(null); const _paintedSourceKeys = []; @@ -1066,24 +1070,26 @@ export function getOverlayPaintRect(sourceId, entryId) { } /** - * Whether an entry is queued to paint in the frame being painted. Custom lanes - * run before the host's own entries, so `getOverlayPaintRect` has nothing for - * this frame yet while they paint; this answers from the solved paint queue. + * Opacity an entry will paint with in the frame being painted, or 0 when it is + * not queued. Valid from the custom lanes, which paint before the entries in + * later lanes publish their paint rects. Leaves out only the Cyber sonar fade. * @param {string} sourceId * @param {string} entryId - * @returns {boolean} + * @returns {number} */ -export function isOverlayEntryQueued(sourceId, entryId) { - if (_destroyed) return false; - const record = _records.get(entryKey(String(sourceId), String(entryId))); - if (!record || record.queuedStamp !== _frameStamp) return false; - // Same channels as the paint, minus the per-frame keyhole and sonar fades. +export function getQueuedOverlayEntryAlpha(sourceId, entryId) { + if (_destroyed) return 0; + const item = _queuedItemBySource.get(sourceId)?.get(entryId); + // Pooled items are reassigned every frame: check the frame and the entry. + if (!item || item.stamp !== _frameStamp) return 0; + const record = item.record; + if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0; return ( record.sourceAlpha * - record.queuedItem.temporalAlpha * - record.distanceAlpha * - record.altitudeAlpha > - 0.001 + item.temporalAlpha * + record.distanceAlpha * + record.altitudeAlpha * + (_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1) ); } @@ -1759,8 +1765,6 @@ function getProjectionRecord(entry) { contentAlpha: 1, altitudeAlpha: 1, sourceAlpha: 1, - queuedStamp: 0, - queuedItem: null, protectedPlacement: null, distanceOptions: {}, altitudeOptions: {}, @@ -2277,12 +2281,27 @@ function addPaintItem(record, placement, temporalAlpha, selected) { item.lane = paintLaneForOverlayEntry(record.entry); item.zIndex = record.entry.zIndex; item.key = record.key; + item.stamp = _frameStamp; _paintQueue[_paintCount++] = item; - // Read by `isOverlayEntryQueued` while the custom lanes paint, before this - // item publishes its paint rect. Only a stamp and a pointer: storing the - // item's alpha here would box a double per paint item per frame. - record.queuedStamp = _frameStamp; - record.queuedItem = item; + // Indexed by source and id rather than by `record.key`, so a lookup builds + // no string. A card fading out after it left its source's entries is still + // queued here, though `_records` no longer has it. + let bySource = _queuedItemBySource.get(record.entry.source); + if (!bySource) { + bySource = new Map(); + _queuedItemBySource.set(record.entry.source, bySource); + } + bySource.set(record.entry.id, item); +} + +/** Drop index rows for entries that stopped painting (bounded, not per frame). */ +function pruneQueuedItemIndex() { + for (const [sourceId, bySource] of _queuedItemBySource) { + for (const [entryId, item] of bySource) { + if (item.stamp !== _frameStamp) bySource.delete(entryId); + } + if (bySource.size === 0) _queuedItemBySource.delete(sourceId); + } } function sourceCanPaint(sourceId) { @@ -2354,6 +2373,7 @@ function solveDomains(timestamp) { solveRevision += domain.arbiter.solveRevision; } _paintQueue.length = _paintCount; + if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex(); _solveDirty = false; _diagnostics.solveMs = solveMs; _diagnostics.selectedCount = selectedCount; @@ -2482,23 +2502,25 @@ function paintCustomLane(lane) { } } +/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */ +function entryKeyholeAlpha(entry, placement, keyhole) { + if (entry.edgeFade !== 'keyhole') return 1; + const keyholeX = placement.centerX - keyhole.centerX; + const keyholeY = placement.centerY - keyhole.centerY; + return keyholeX * keyholeX + keyholeY * keyholeY <= + keyhole.radius * keyhole.radius + ? 1 + : keyholeLabelAlphaFromGeometry( + placement.centerX, + placement.centerY, + keyhole, + ); +} + function paintEntryItem(item, keyhole, sonar) { const { record, placement } = item; const entry = record.entry; - let keyholeAlpha = 1; - if (entry.edgeFade === 'keyhole') { - const keyholeX = placement.centerX - keyhole.centerX; - const keyholeY = placement.centerY - keyhole.centerY; - keyholeAlpha = - keyholeX * keyholeX + keyholeY * keyholeY <= - keyhole.radius * keyhole.radius - ? 1 - : keyholeLabelAlphaFromGeometry( - placement.centerX, - placement.centerY, - keyhole, - ); - } + const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole); // All five channels are normalized at their source. Keep the multiply on // the hot paint path so its intermediate doubles remain unboxed; the pure // `combinedOverlayAlpha` export still specifies/tests the same binding. @@ -2799,6 +2821,7 @@ export function destroyWorldOverlay() { _accessibleActivatorByKey.clear(); _accessibilitySignature = ''; _paintRectByKey.clear(); + _queuedItemBySource.clear(); _paintCount = 0; _paintRectCount = 0; _hitRectCount = 0; diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index a0b5160b6..6e713a5da 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -24,7 +24,7 @@ import { activateWorldOverlayAction, initWorldOverlay, subscribeWorldOverlayActions, - isOverlayEntryQueued, + getQueuedOverlayEntryAlpha, isOverlayPointVisible, normalizeOverlayEntry, paintLaneForOverlayEntry, @@ -1216,16 +1216,16 @@ test('custom detection lane receives the shared host frame and paints below ordi test('a detection lane sees which cards this frame will paint before they publish paint rects', () => { // The detection lane paints first, so a card's paint rectangle for this - // frame does not exist yet when it runs. The queued check is what lets a + // frame does not exist yet when it runs. The queued alpha is what lets a // contact's callout give way to that contact's own card in the same frame. const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 }); initWorldOverlay(env.viewer); const seen = []; registerWorldOverlayPaintLane('detection', () => { seen.push({ - shown: isOverlayEntryQueued('cards', 'SHOWN'), - hidden: isOverlayEntryQueued('hidden-cards', 'HIDDEN'), - unknown: isOverlayEntryQueued('cards', 'NOT-PUBLISHED'), + shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'), + hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'), + unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'), rect: getOverlayPaintRect('cards', 'SHOWN'), }); }, { id: 'detection', active: true, target: 'detection' }); @@ -1238,16 +1238,16 @@ test('a detection lane sees which cards this frame will paint before they publis setOverlaySourceVisible('hidden-cards', false); env.postRender.raise(); - assert.deepEqual(seen[0], { shown: true, hidden: false, unknown: false, rect: null }, + assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null }, 'the card is queued while its paint rect is still unpublished'); assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane'); assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null); clearOverlaySource('cards'); env.postRender.raise(); - assert.equal(seen[1].shown, false, 'a card cleared since the last frame is no longer queued'); + assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued'); destroyWorldOverlay(); - assert.equal(isOverlayEntryQueued('cards', 'SHOWN'), false); + assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0); env.cleanup(); }); diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index e2a4c6240..93b47e7f3 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) { : `FL${String(180 + (index % 220)).padStart(3, '0')}`, type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4], tier: index % 17 === 0 ? 'military' : undefined, + // Vessels name their ambient card, so each callout asks the host for it. + cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined, + cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined, }); } return { From 451f3e41cf97e4de690baa1fb64ba243e08c086a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 5 Nov 2026 18:25:21 +0000 Subject: [PATCH 036/181] fix(street-level): links keep the recipient's preferences; aim under the screen centre Review fixes for gekh's #1113 round: - a link runs show_street_level with origin 'programmatic', so its source switch and layer are not saved as the recipient's preferences; - with no place named, the action looks under the screen centre (aim 'screen-centre', armed false), keeps the sky refusal and leaves a link's armed point alone; - an armed OPEN aimed at the sky opens nothing instead of the far link point; - an imagery-stage failure is spoken as an error, missing imagery as missing (street.nearestOutcome); the card says "the view centre" in the interface language; - every photo the action opens reveals its panel, and revealPanel scrolls the panel into view in the phone sheet; - embed: EXPAND survives embed's own clean view, the lifted photo hides in cockpit, recording and scene playback, and a failed photo step closes the earlier photo before the view flies; - links pasted into an open tab run their street-level step after the restore settles; a superseded start link does not; - Mapillary's nearest search gives up after 12 s, body included. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 9 +- docs/CURRENT-STATE.md | 35 +++-- src/app/embed.js | 103 ++++++++++---- src/app/embed.test.mjs | 130 +++++++++++++++++- src/app/tools.js | 3 +- src/layers/streetLevel/index.js | 23 +++- src/layers/streetLevel/index.test.mjs | 112 +++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 6 + .../streetLevel/providers/mapillary/source.js | 45 +++++- .../providers/mapillary/source.test.mjs | 41 ++++++ src/layers/streetLevel/state.js | 2 + src/streetLevelMarkup.test.mjs | 9 ++ src/ui/applicationShell.js | 2 + src/ui/revealPanel.test.mjs | 24 ++++ src/ui/streetLevelControls.js | 10 +- src/ui/streetLevelControls.test.mjs | 30 ++++ src/ui/styles/embed.css | 8 ++ src/voice/gevActions.js | 50 ++++++- src/voice/gevActions.test.mjs | 82 ++++++++++- src/voice/speech.js | 17 ++- src/voice/speech.test.mjs | 37 ++++- 22 files changed, 716 insertions(+), 64 deletions(-) create mode 100644 src/ui/revealPanel.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 2223c2fda..4f1714e16 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -131,7 +131,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp` - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. -- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. +- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it; with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. ## Scope & expectations diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index dd9cdf6fd..cdb83575f 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -21,5 +21,12 @@ honours is on, the answer names the filter. Cancelled lookups clear their loading state over an existing photo and release their caller abort listeners. Embedded photos show loading errors inside the overlay, and the OPEN prompt - uses the recipient's selected language (Said Turksever, @asturksever, + uses the recipient's selected language. A link's photos never save its + layers as the recipient's preferences, and a link pasted into an open tab + opens or arms them too. With no place named, the action looks under the + screen centre as OPEN does; an armed OPEN aimed at the sky opens nothing; a + failing source is reported as an error, not as missing imagery; and + Mapillary's nearest search gives up after 12 seconds. On a phone the panel + scrolls into view, and in embed mode EXPAND stays open while cockpit, + recording and scene playback hide the photo (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a998c6edc..2b1c3c1c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4483,26 +4483,35 @@ app. (voice and typed; `src/voice/gevActions.js`) is one call: it checks the source has its key (a keyless source explains itself and changes nothing, as a chip does), flies to the place at street framing and waits for arrival (a -name, coordinates, a pointed spot, a numbered item, or nothing for the view -centre), switches to the map the source needs (Google Street View: Google 3D), +name, coordinates, a pointed spot, a numbered item, or nothing for the +ground under the screen centre, as OPEN uses: `aim: 'screen-centre'` with +`armed: false`, so sky refuses and a link's armed point is neither used nor +dropped), switches to the map the source needs (Google Street View: Google 3D), lights that source through `setLayerParams` with origin `voice` (one source at -a time, kept in the share link like a chip click), turns the layer on and +a time, kept in the share link like a chip click; a link runs the action with +origin `programmatic`, for the source switch and the layer, so it never writes +the recipient's saved layer state), turns the layer on and calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, recording or scene playback hides the panel, checked after the flight and before anything switches or loads; embed mode exempts its own clean-view class because the photo is lifted above the globe; an empty `imagery` result names a -narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a +narrowing 360°/flat or SINCE filter the source honours, as `filter`; +`noImagery` marks an empty result, `imageryError` a source or viewer failure +and `aim: 'sky'` a sky centre, from the layer's `street.nearestOutcome`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. For a source without a coverage layer the lookup widens to 250 m (`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); -clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it +clicks keep 50 m. Mapillary's search gives up after 12 s, body included +(`NEAREST_TIMEOUT_MS`), and asks for 100 photos, the API's most: it returns the radius's photos in no particular order and cuts them at the limit first, so the distance sort and the imagery filter run over the spot's photos. On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a -link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and +link that arms, open that sheet and scroll the panel into view +(`revealPanel`); every photo the action opens reveals the panel again, also +one the user had collapsed. The layer exposes `requiredMapStack(id)` and `providerStatus(id)` for callers that must decide before the panel lists a source, and `coverageLines(id)` (the lines a provider draws now, current and stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, @@ -4513,10 +4522,15 @@ source and point, defaulting to where the camera looks; a `google` source brings `map: photoreal` and refuses another map; not with `follow`), links as `sl=,,`; `show_in_gods_eye_view` accepts it, the globe panel applies it through the action, and a plain link runs the action once restored -(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the +(one shot: the rewritten hash drops `sl`). A link pasted into the open tab +runs it after its restore settles (`gev:share-navigation-restore-settled`), +and a start link whose restore a newer one superseded never runs. In embed mode the rail holding the panel is hidden, so an open photo moves to an overlay on the globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full -screen, Esc leaves full screen first). Image-loading failures render inside +screen, Esc leaves full screen first). EXPAND stays open under embed mode's +own clean view; cockpit, recording and scene playback hide the overlay. A +posted view whose photo step fails closes the earlier photo before its +camera flies. Image-loading failures render inside the embedded overlay, since the panel's error region is hidden with the rail. A caller's abort is honoured through the viewer's mount and load, not just the lookup, and only @@ -4529,8 +4543,9 @@ link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's point and radius, consumed only by the next OPEN press (`aim: -'screen-centre'`), and only while the view centre is within 1 km of the point -(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source +'screen-centre'`), and only while the ground under the screen centre is within +1 km of the point (`ARMED_REACH_M`; a user who moved on opens what the view +shows) or cannot be picked; a sky centre refuses; any other lookup, open, close, disable, its source switched off or its map leaving drops it), opens the panel and a toast points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline embed) for a tool result issued in the last 5 minutes, and posted views, open diff --git a/src/app/embed.js b/src/app/embed.js index 8f439bf41..87ecbb386 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -198,6 +198,8 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { } const STREET_LEVEL_PANEL_ID = 'street-level-panel'; +/** Dispatched on the window when a link opened after start-up has restored. */ +const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled'; /** The armed link's toast, naming the OPEN button as the panel shows it. */ function armedToast() { @@ -258,8 +260,12 @@ export async function applyView( 'show_street_level', streetLevelArgs(view.street_level), ); - if (result?.ok !== true && !result?.location) - steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + if (result?.ok !== true) { + // An earlier view's photo must not stay over a place this view left. + dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.(); + if (!result?.location) + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + } return steps; } if (!view.follow) { @@ -317,13 +323,16 @@ export function installViews({ // The camera's owner at restore: anyone who navigates while the link's // steps wait (a gesture, a search, the assistant) takes it, and a passive // link must not fly over them. + // A start-up restore that a newer link superseded hands the camera to that + // link: its own steps never run. let generationAtRestore = null; - let queue = ready.then(() => { + let startSuperseded = false; + let queue = ready.then((outcome) => { generationAtRestore = shell._navigationGeneration; + startSuperseded = outcome?.status === 'superseded'; }); - const navigatedSinceRestore = () => - Number.isFinite(generationAtRestore) && - shell._navigationGeneration !== generationAtRestore; + const navigatedSince = (generation) => + Number.isFinite(generation) && shell._navigationGeneration !== generation; const linkStep = (work) => queue.then(async () => { if (signal?.aborted) return; @@ -341,29 +350,64 @@ export function installViews({ // click; only the panel page's own link for a result issued just now is // the client's request (a host remounting the panel hands back old ones). // A link that follows an entity keeps the camera on it. - const streetLevel = followFromParams(hashParams) - ? null - : streetLevelFromParams(hashParams); - if (streetLevel) - queue = linkStep(async () => { - if (navigatedSinceRestore()) return; - const result = await run( - 'show_street_level', - streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }), - ); - // The restore window keeps the panel collapsed; OPEN must be in view. - // Embed mode hides the rail that holds it, so there the link only arms - // (a posted view is the way to open a photo in a frame). - if (result?.armed === true && !isEmbedded(location)) { - shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { - explicit: true, - }); - // On a phone the panel sits in a tab sheet: open it so OPEN shows. - shell.revealPanel?.(STREET_LEVEL_PANEL_ID); - shell._showToast?.(armedToast()); - } - }); - if (!isEmbedded(location)) return () => {}; + const linkedStreetLevel = (params) => + followFromParams(params) ? null : streetLevelFromParams(params); + const openLinkedStreetLevel = async ( + streetLevel, + { generation, autoOpen = false }, + ) => { + if (navigatedSince(generation)) return; + // The link replays the sender's view: nothing it switches becomes the + // recipient's own preference. + const result = await run( + 'show_street_level', + streetLevelArgs(streetLevel, { autoOpen }), + { origin: 'programmatic' }, + ); + // The restore window keeps the panel collapsed; OPEN must be in view. + // Embed mode hides the rail that holds it, so there the link only arms + // (a posted view is the way to open a photo in a frame). + if (result?.armed === true && !isEmbedded(location)) { + shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + explicit: true, + }); + // On a phone the panel sits in a tab sheet: open it so OPEN shows. + shell.revealPanel?.(STREET_LEVEL_PANEL_ID); + shell._showToast?.(armedToast()); + } + }; + const startStreetLevel = linkedStreetLevel(hashParams); + if (startStreetLevel) + queue = linkStep(() => + startSuperseded + ? undefined + : openLinkedStreetLevel(startStreetLevel, { + generation: generationAtRestore, + autoOpen: isFreshPanelResult(), + }), + ); + // A link pasted into the open tab restores camera and layers on its own; + // its photos follow once that restore settles, under the same rule: a plain + // link never loads a billed photo by itself. + const onNavigatedRestore = (event) => { + if (event?.detail?.status !== 'settled') return; + const streetLevel = linkedStreetLevel( + new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + ); + if (!streetLevel) return; + const generation = shell._navigationGeneration; + queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation })); + }; + windowRef?.addEventListener?.( + SHARE_NAVIGATION_RESTORED_EVENT, + onNavigatedRestore, + ); + const stopNavigatedRestores = () => + windowRef?.removeEventListener?.( + SHARE_NAVIGATION_RESTORED_EVENT, + onNavigatedRestore, + ); + if (!isEmbedded(location)) return stopNavigatedRestores; windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); @@ -423,6 +467,7 @@ export function installViews({ }); return () => { stopRendering(); + stopNavigatedRestores(); windowRef.removeEventListener('message', onMessage); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index fb70315ff..a7cf6b61e 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({ }); const recorder = (answers = {}) => { const calls = []; - const run = async (name, args) => { + const options = []; + const run = async (name, args, runOptions) => { calls.push([name, args]); + options.push([name, runOptions]); const answer = answers[name]; return typeof answer === 'function' ? answer(calls) : (answer ?? { ok: true }); }; - return { calls, run }; + return { calls, run, options }; }; test('embed mode is opt-in by query parameter', () => { @@ -875,3 +877,127 @@ test('a passive link stands down when someone navigated while it waited behind i await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(quiet, ['annotate_map', 'show_street_level']); }); + +test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => { + const { calls, run, options } = recorder(); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1); + assert.deepEqual(options, [ + ['show_street_level', { origin: 'programmatic' }], + ]); +}); + +test('a start link whose restore a newer link superseded opens nothing', async () => { + const { calls, run } = recorder(); + installViews({ + shell: { + initialRestorePromise: Promise.resolve({ status: 'superseded' }), + _navigationGeneration: 4, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'start link A does not pull the camera from B'); +}); + +test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => { + const { calls, run, options } = recorder(); + const windowRef = new EventTarget(); + const location = { search: '', hash: '#v=2&lat=1&lon=2' }; + const shell = { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 1, + }; + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'the start link has no photos'); + const settled = (status) => + windowRef.dispatchEvent( + Object.assign(new Event('gev:share-navigation-restore-settled'), { + detail: { status }, + }), + ); + location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950'; + shell._navigationGeneration = 2; // the restore's own flight + settled('failed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'a failed restore opens nothing'); + settled('settled'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + [ + 'show_street_level', + { + latitude: 48.8874, + longitude: 2.3495, + provider: 'mapillary', + fly: true, + autoOpen: false, + }, + ], + ]); + assert.deepEqual(options.at(-1), [ + 'show_street_level', + { origin: 'programmatic' }, + ]); + remove(); + location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000'; + settled('settled'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1, 'removed with the install'); +}); + +test('a view whose photos fail closes the photo an earlier view opened', async () => { + const closed = []; + const dataManager = { + ...fakeLayers(new Set()), + layers: new Map([ + ['street-level', { module: { closeViewer: () => closed.push(true) } }], + ]), + }; + const { run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const viewer = fakeViewer(); + await applyView(view, { viewer, dataManager, run }); + assert.equal(closed.length, 1, 'closed before the view flies away'); + assert.equal(viewer.flights.length, 1); + const opened = recorder({ show_street_level: () => ({ ok: true }) }); + await applyView(view, { viewer, dataManager, run: opened.run }); + assert.equal(closed.length, 1, 'an opened photo stays'); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..39456d3eb 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -223,7 +223,8 @@ export function createApplicationTools({ shell: styleManager, viewer, dataManager, - run: (name, args) => voiceCommands.runner(name, args, { signal }), + run: (name, args, options = {}) => + voiceCommands.runner(name, args, { ...options, signal }), signal, }), ); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index ad24da557..1c380b7e3 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -668,7 +668,10 @@ export function createStreetLevelLayer({ * providers that can (a geocoded landmark is rarely within 50 m of a * street); clicks keep the default. A caller's `signal` retires the * lookup: nothing opens after it fires. Providers without - * `nearestImage` are skipped. + * `nearestImage` are skipped. `armed: false` (a request of its own, such + * as the assistant's) neither uses nor drops a link's armed point. Why + * nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery + * in reach), 'sky' (the screen centre is not ground), or null (an error). */ async openNearest( point, @@ -678,18 +681,25 @@ export function createStreetLevelLayer({ aim = 'view', radiusM = null, signal: callerSignal = null, + armed: useArmed = true, } = {}, ) { if (callerSignal?.aborted) return false; + state.street.nearestOutcome = null; // An OPEN press after a link armed a lookup opens what the link asked; // any other lookup is a new request and drops the armed one. - const armed = state.street.armed; - state.street.armed = null; + const armed = useArmed ? state.street.armed : null; + if (useArmed) state.street.armed = null; if (!point && aim === 'screen-centre' && armed) { // The button promises the centre of the view: the link's spot only - // while the view still shows it. + // while the view still shows it, or when the view cannot be picked. + // Sky refuses below, as any OPEN press does. const centre = armed.point ? screenCentreGround() : null; - if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) { + if (!armed.point) radiusM ??= armed.radiusM; + else if ( + centre === undefined || + (centre && metresBetween(centre, armed.point) <= ARMED_REACH_M) + ) { point = armed.point; radiusM ??= armed.radiusM; } @@ -704,6 +714,7 @@ export function createStreetLevelLayer({ abortNearest(); state.street.loading = false; state.street.error = AIM_AT_GROUND_MESSAGE; + state.street.nearestOutcome = 'sky'; notify(); return false; } @@ -779,6 +790,8 @@ export function createStreetLevelLayer({ ); // A provider's own refusal is English data that the panel translates; // the core's refusal is built here, for the panel only, in the language. + if (state.enabled && stillOn && !lastError) + state.street.nearestOutcome = 'empty'; if (state.enabled && stillOn) state.street.error = lastError?.message || diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 191246d92..c0f6797e8 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1942,3 +1942,115 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( const view = presentStreetLevelPanel(layer.getUIState()); assert.match(view.meta, /Google Street View needs the Google 3D map/); }); + +test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point) => { + lookups.push(point); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + // Every pick misses the ground: the screen centre is sky. + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: () => undefined, + }); + viewer.globe = { pick: () => undefined }; + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 }); + assert.equal( + await layer.openNearest(undefined, { aim: 'screen-centre' }), + false, + ); + assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky'); + const street = layer.getUIState().street; + assert.equal(street.nearestOutcome, 'sky'); + assert.match(street.error, /Aim the middle of the view/); +}); + +test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + // About 120 m away: an OPEN press would take the link's spot. + layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre', armed: false }); + const asked = lookups.at(-1); + assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre'); + assert.equal(asked.radiusM, null, 'its own radius'); + assert.equal(layer.getUIState().street.nearestOutcome, 'empty'); + assert.deepEqual( + layer.getUIState().street.armed?.point, + { lat: 38.5808, lon: -121.494 }, + 'still armed for the OPEN press', + ); +}); + +test('a failing source is not reported as missing imagery', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const provider = fakeProvider({ + nearestImage: async () => { + throw new Error('Mapillary graph HTTP 500'); + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const layer = create({ providers: [provider] }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false); + const street = layer.getUIState().street; + assert.equal(street.nearestOutcome, null); + assert.equal(street.error, 'Mapillary graph HTTP 500'); +}); diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 64b5d8073..b582b8cdd 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -82,6 +82,12 @@ export const SEQUENCE_IMAGE_FIELDS = * the imagery filter then choose among the spot's photos, not a few of them. */ export const NEAREST_LIMIT = 100; +/** + * A nearest-image search that has not answered (headers and body) in this + * long is given up: a link or a posted view waits on it, and so does every + * view queued behind that one. + */ +export const NEAREST_TIMEOUT_MS = 12_000; /** Fields a nearest-image search needs: where each is, and the filter's. */ export const NEAREST_IMAGE_FIELDS = 'id,captured_at,geometry,computed_geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 022a14dac..4830d54e2 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -3,6 +3,7 @@ import { MAPILLARY_GRAPH_HOST, NEAREST_IMAGE_FIELDS, NEAREST_LIMIT, + NEAREST_TIMEOUT_MS, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -69,7 +70,47 @@ export function createMapillarySource({ ...endpoints, }; - async function graph(path, params = {}, { signal } = {}) { + async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) { + if (!timeoutMs) return graphOnce(path, params, { signal }); + // The whole answer, body included, within `timeoutMs`; the caller's + // abort still wins. + if (signal?.aborted) throw signal.reason; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort( + new MapillarySourceError('Mapillary did not answer in time', { + status: 504, + }), + ), + timeoutMs, + ); + try { + // A body that never ends is given up too, whatever the fetch does. + const gaveUp = new Promise((_, reject) => + controller.signal.addEventListener( + 'abort', + () => reject(controller.signal.reason), + { once: true }, + ), + ); + return await Promise.race([ + graphOnce(path, params, { signal: controller.signal }), + gaveUp, + ]); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + async function graphOnce(path, params, { signal }) { if (graphBase) { const search = new URLSearchParams(params); const response = await fetchImpl(`${graphBase}${path}?${search}`, { @@ -165,7 +206,7 @@ export function createMapillarySource({ limit: String(Math.min(100, Math.max(1, limit))), fields: NEAREST_IMAGE_FIELDS, }, - { signal }, + { signal, timeoutMs: NEAREST_TIMEOUT_MS }, ); return Array.isArray(payload?.data) ? payload.data : []; }, diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index 883918f4f..dfa733a7f 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -179,3 +179,44 @@ test('Graph endpoint errors keep their status', async () => { (error) => error.status === 503 && error.keyRequired === true, ); }); + +test('a nearest-image search that never answers is given up, body included', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { NEAREST_TIMEOUT_MS } = await import('./policy.js'); + // Headers arrive; the body never ends, and the fetch ignores its signal. + const { fetchImpl } = fakeFetch(() => ({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + })); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const pending = source.nearestImages({ lat: 1, lon: 2 }); + const outcome = pending.then( + () => 'answered', + (error) => error, + ); + await Promise.resolve(); + t.mock.timers.tick(NEAREST_TIMEOUT_MS); + const error = await outcome; + assert.ok(error instanceof MapillarySourceError, String(error)); + assert.equal(error.status, 504); + assert.equal(error.keyRequired, false, 'not a key problem'); +}); + +test('the caller’s abort still ends a nearest-image search before its deadline', async () => { + const { fetchImpl } = fakeFetch( + (_url, init) => + new Promise((_, reject) => + init.signal.addEventListener('abort', () => reject(init.signal.reason)), + ), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const controller = new AbortController(); + const pending = source.nearestImages( + { lat: 1, lon: 2 }, + { signal: controller.signal }, + ); + const reason = new Error('the turn moved on'); + controller.abort(reason); + await assert.rejects(pending, (error) => error === reason); +}); diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index 7408b7dee..283e5eff6 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -19,6 +19,8 @@ export function freshStreet() { capturedAt: null, /** 'day', or 'month' for providers that date images by month (Google). */ capturedAtPrecision: 'day', + /** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */ + nearestOutcome: null, /** A place name for the caption (Google's address), or null. */ title: null, /** Whether the open viewer has FIT/FILL render modes. */ diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 602a3b065..c7e848c3d 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -123,6 +123,15 @@ test('embed mode shows an open photo over the globe, filling the overlay', () => assert.ok(overlay, 'embed styles the viewer it moves over the globe'); for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000']) assert.ok(overlay[1].includes(rule), rule); + // Cockpit, recording and scene playback own the screen: the photo hides. + for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode']) + assert.match( + css, + new RegExp( + `body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`, + ), + mode, + ); // The photo takes the overlay's height, as it does full screen. assert.match( css, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 80d20731e..fd509cf51 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1222,6 +1222,8 @@ export class StyleManager extends ShellFacade { revealPanel(panelId) { const sheet = sheetForPanelId(panelId); if (sheet) this._mobileShell?.setSheet(sheet); + // The sheet opens at its top; a panel lower in the rail is scrolled to. + document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' }); } /** diff --git a/src/ui/revealPanel.test.mjs b/src/ui/revealPanel.test.mjs new file mode 100644 index 000000000..5ed7696ac --- /dev/null +++ b/src/ui/revealPanel.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; + +test('revealing a panel opens its phone sheet and scrolls the panel into view', () => { + const sheets = []; + const scrolls = []; + const panel = { scrollIntoView: (options) => scrolls.push(options) }; + const saved = globalThis.document; + globalThis.document = { + getElementById: (id) => (id === 'street-level-panel' ? panel : null), + }; + try { + StyleManager.prototype.revealPanel.call( + { _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } }, + 'street-level-panel', + ); + } finally { + globalThis.document = saved; + } + assert.deepEqual(sheets, ['cameras']); + // The panel sits below the sheet's first screen at 375×812. + assert.deepEqual(scrolls, [{ block: 'nearest' }]); +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index fd2fdaee3..b84c8b9e1 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -285,8 +285,16 @@ export class StreetLevelControls { this._modesObserver = null; return; } + // Embed mode keeps Clean View on for its globe while the photo is lifted + // over it: there only recording and the cockpit end EXPAND. const leaveIfHiding = () => { - if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode))) + if ( + PANEL_HIDING_MODES.some( + (mode) => + body.classList.contains(mode) && + !(mode === 'ui-clean-view' && isEmbedPage()), + ) + ) this._exitExpanded(); }; if (!this._modesObserver && typeof MutationObserver === 'function') { diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index d6d3927b8..e375b8413 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode']) } })); +test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () => + withDom(async (dom) => { + const saved = globalThis.MutationObserver; + const watch = fakeMutationObserver(); + globalThis.MutationObserver = watch.FakeObserver; + try { + dom.document.body.classList.add('ui-embed'); + dom.document.body.classList.add('ui-clean-view'); + const { layer, controls } = await stubPanel( + dom, + uiState({ open: false }), + ); + await layer.publish(uiState({ open: true })); + dom.document.querySelector('#sl-viewer-expand').click(); + await settle(); + await settle(); + watch.fire(); + await settle(); + assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open'); + dom.document.body.classList.toggle('cockpit-mode', true); + watch.fire(); + await settle(); + await settle(); + assert.equal(controls.isViewerExpanded(), false); + controls.destroy(); + } finally { + globalThis.MutationObserver = saved; + } + })); + /* ── Restores and keyboard rules ───────────────────────────────────────── */ test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () => diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index a376ac5da..7810ad1be 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -22,6 +22,14 @@ body.ui-embed .sl-viewer-wrap-embed { z-index: 1000; } +/* Cockpit, recording and scene playback own the screen, as they hide the + panel elsewhere: the lifted photo steps out of their way. */ +body.ui-embed.cockpit-mode .sl-viewer-wrap-embed, +body.ui-embed.recording-mode .sl-viewer-wrap-embed, +body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed { + display: none; +} + body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage, body.ui-embed .sl-viewer-wrap-embed .sl-viewer { flex: 1 1 auto; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 7f18ed0cd..ec68cffa1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -465,7 +465,7 @@ export function createGevActionRunner({ const pending = dataManager.getAll?.().find((l) => l.id === layerId); reportProgress('layer', pending?.name || layerId); } - const changeOptions = { origin: 'voice' }; + const changeOptions = { origin: actionIntentOrigin(runOptions) }; if (runOptions.signal) changeOptions.signal = runOptions.signal; let changed = false; let changeError = null; @@ -3446,6 +3446,18 @@ function normalizeStyle(value) { return null; } +/** The application panel that holds the street-level photo. */ +const STREET_LEVEL_PANEL_ID = 'street-level-panel'; + +/** + * The layer-state origin of an action's changes. The assistant's are the + * user's own choices and persist; a link replays someone else's view + * (`origin: 'programmatic'`) and never writes the recipient's preferences. + */ +function actionIntentOrigin(runOptions) { + return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice'; +} + /** * "Show me on Street View / on Mapillary": fly to the place, make sure * the imagery source's map is up, light that source in Street Level, turn the @@ -3482,8 +3494,10 @@ export async function showStreetLevel( ...(provider ? { provider } : {}), ...(providerName ? { providerName } : {}), ...(label ? { label } : {}), + ...(atViewCentre ? { atViewCentre } : {}), ...extra, }); + let atViewCentre = false; const query = String(args.query || '').trim(); const latitude = Number(args.latitude); const longitude = Number(args.longitude); @@ -3540,7 +3554,7 @@ export async function showStreetLevel( (entity ? String(entity.label || entity.id) : '') || (hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') || 'the view centre'; - const atViewCentre = !query && !hasCoordinates && !entity; + atViewCentre = !query && !hasCoordinates && !entity; let point = null; let location = null; if (args.fly === false || atViewCentre) { @@ -3564,7 +3578,8 @@ export async function showStreetLevel( point = { lat: place.lat, lon: place.lng }; label = place.label || query; } - // No place at all: the layer looks under the view centre. + // No place at all: the layer looks at the ground under the screen + // centre, as the OPEN button does. } else { location = await runGevAction( 'fly_to_location', @@ -3646,7 +3661,7 @@ export async function showStreetLevel( if (typeof dataManager.setLayerParams === 'function') accepted = dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, { - origin: 'voice', + origin: actionIntentOrigin(runOptions), }) !== false; else layerModule.setProviderEnabled?.(provider, true); if (!accepted) @@ -3712,6 +3727,9 @@ export async function showStreetLevel( const opened = await layerModule.openNearest(point, { providerIds: [provider], frame: false, + // Under the screen centre, not the middle of a tilted view's footprint; + // this request is the user's own, not a link's armed one. + ...(point ? {} : { aim: 'screen-centre', armed: false }), ...(runOptions.signal ? { signal: runOptions.signal } : {}), ...(snapshot.groundClick === true ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } @@ -3744,6 +3762,19 @@ export async function showStreetLevel( street.error || 'Street Level changed while it looked for imagery', { cancelled: true, location }, ); + // The screen centre is sky: nothing was looked up. + if (street.nearestOutcome === 'sky') + return fail('imagery', street.error || 'Aim the view at a street', { + location, + aim: 'sky', + }); + // The source or the viewer failed: an error, not missing coverage. + if (street.nearestOutcome !== 'empty') + return fail( + 'imagery', + street.error || `${providerName} imagery could not be opened`, + { location, imageryError: true }, + ); // An imagery filter the source honours can hide every photo here; say // so, or the answer reads as "no coverage" when one switch would do. const filter = activeImageryFilter(layerModule.getUIState?.()); @@ -3751,9 +3782,17 @@ export async function showStreetLevel( return fail( 'imagery', filter ? `${reason} with the ${filter.label} on` : reason, - { location, ...(filter ? { filter } : {}) }, + { location, noImagery: true, ...(filter ? { filter } : {}) }, ); } + // Every explicit request shows its photo, also one replacing a photo whose + // panel or phone sheet the user had closed. Embed mode lifts the photo. + if (!globalThis.document?.body?.classList?.contains('ui-embed')) { + styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + persist: false, + }); + styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID); + } return { ok: true, action, @@ -3767,6 +3806,7 @@ export async function showStreetLevel( position: street.position ?? null, externalUrl: street.externalUrl ?? null, flew: location !== null, + ...(atViewCentre ? { atViewCentre } : {}), ...(location ? { location: location.label } : {}), }; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f059a24b6..775aea8d2 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -5715,8 +5715,13 @@ function createStreetLevelHarness({ duringLookup = null, keyRejectedAtStart = false, lightFails = false, + imageryError = null, + sky = false, } = {}) { const order = []; + const lookups = []; + const origins = []; + const revealed = []; const { viewer, styleManager } = createVoiceNavigationHarness(); let completeFlight = null; viewer.camera.lookAt = () => {}; @@ -5776,6 +5781,7 @@ function createStreetLevelHarness({ order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`); }, async openNearest(point, options) { + lookups.push({ point, ...options }); order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`); if (options.signal?.aborted) return false; if (duringLookup) { @@ -5790,7 +5796,13 @@ function createStreetLevelHarness({ return false; } } + if (imageryError) { + street.error = imageryError; + street.nearestOutcome = null; + return false; + } if (!nearest) { + street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty'; street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`; return false; } @@ -5805,12 +5817,14 @@ function createStreetLevelHarness({ isEnabled: () => enabled, getLayerParams: () => ({ ...params }), setLayerParams(id, next, { origin }) { + origins.push(origin); order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`); if (paramsAccepted === false) return false; if (!lightFails) params = { ...params, ...next }; return true; }, - async setEnabled(id) { + async setEnabled(id, _on, options) { + origins.push(options?.origin); order.push(`enable:${id}`); if (enableResult === false) return false; enabled = true; @@ -5818,8 +5832,10 @@ function createStreetLevelHarness({ }, getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], }; + styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`); + styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`); const runner = createGevActionRunner({ viewer, styleManager, dataManager }); - return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager }; + return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager, lookups, origins, revealed }; } /** A loaded CCTV camera the entity branch can locate. */ @@ -6403,3 +6419,65 @@ test('show_street_level opens over the clean embedded globe but respects its oth globalThis.document = saved; } }); + +test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const link = createStreetLevelHarness(); + const linked = await link.runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }, + { origin: 'programmatic' }, + ); + assert.equal(linked.ok, true, linked.error); + assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on'); + const asked = createStreetLevelHarness(); + await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice'); +}); + +test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const here = createStreetLevelHarness({ enabled: true }); + const result = await here.runner('show_street_level', { provider: 'mapillary' }); + assert.equal(result.ok, true, result.error); + assert.equal(result.atViewCentre, true); + assert.equal(here.lookups[0].point, null); + assert.equal(here.lookups[0].aim, 'screen-centre'); + assert.equal(here.lookups[0].armed, false); + const placed = createStreetLevelHarness({ enabled: true }); + await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is'); + const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true }); + const refused = await sky.runner('show_street_level', { provider: 'mapillary' }); + assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']); +}); + +test('show_street_level tells missing imagery from a failing source', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'mapillary', fly: false, + }); + assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]); + const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }, + ); + assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]); + assert.equal(broken.error, 'Mapillary graph HTTP 500'); +}); + +test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createStreetLevelHarness({ enabled: true }); + await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false }); + assert.deepEqual(harness.revealed, [ + 'collapse:street-level-panel:false', + 'reveal:street-level-panel', + 'collapse:street-level-panel:false', + 'reveal:street-level-panel', + ]); + const missing = createStreetLevelHarness({ enabled: true, nearest: null }); + await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed'); +}); diff --git a/src/voice/speech.js b/src/voice/speech.js index cd52adaa1..edb36e8a6 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -626,7 +626,11 @@ function showStreetLevel(result) { const shownSource = result?.providerName ? spokenLabel(result.providerName) : t('Street Level'); - const shown = spokenLabel(localizePlaceName(result?.label || '')) || place; + // The view centre is a phrase, not a place name: the card says it in the + // interface language. + const shown = result?.atViewCentre + ? t('the view centre') + : spokenLabel(localizePlaceName(result?.label || '')) || place; if (result?.cancelled) return null; if (!result?.ok) { let say; @@ -640,10 +644,15 @@ function showStreetLevel(result) { else if (result?.stage === 'layer') say = "Couldn't turn on Street Level."; else if (result?.stage === 'view') say = 'Street-level photos are hidden in this view. Leave it first.'; + else if (result?.stage === 'imagery' && result?.aim === 'sky') + say = 'Aim the middle of the view at a street first.'; + else if (result?.stage === 'imagery' && result?.filter?.label) + say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`; + else if (result?.stage === 'imagery' && result?.noImagery === true) + say = `No ${source} imagery near ${place}.`; + // A service or viewer failure is not missing coverage. else if (result?.stage === 'imagery') - say = result?.filter?.label - ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` - : `No ${source} imagery near ${place}.`; + say = `${source} could not open imagery near ${place}.`; else say = `Couldn't open ${source}.`; return { say, diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index e12c0feed..84d6f8d62 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -536,6 +536,22 @@ test('labels are bounded and cleaned before they are spoken', () => { ); }); +test('the street-level card says "the view centre" in the interface language', () => { + setLocale('de'); + try { + const out = envelope('show_street_level', { + ok: true, + providerName: 'Mapillary', + label: 'the view centre', + atViewCentre: true, + }); + assert.equal(out.display.lines[0], 'die Bildmitte'); + assert.match(out.say, /the view centre/, 'speech stays English'); + } finally { + setLocale('en'); + } +}); + test('street-level speech names the source and the place, and says which step failed', () => { const ok = envelope('show_street_level', { ok: true, @@ -556,7 +572,8 @@ test('street-level speech names the source and the place, and says which step fa map: "Google Street View needs the Google 3D map, which isn't available.", key: 'Google Street View needs its key.', layer: "Couldn't turn on Street Level.", - imagery: 'No Google Street View imagery near Nowhere.', + // A service or viewer failure, not missing coverage. + imagery: 'Google Street View could not open imagery near Nowhere.', }; for (const [stage, say] of Object.entries(stages)) { const out = envelope('show_street_level', { @@ -570,6 +587,24 @@ test('street-level speech names the source and the place, and says which step fa assertSpeakable(out.say); assert.deepEqual(out.display.lines, ['detail']); } + const empty = envelope('show_street_level', { + ok: false, + stage: 'imagery', + noImagery: true, + label: 'Nowhere', + providerName: 'Mapillary', + }); + assert.equal(empty.say, 'No Mapillary imagery near Nowhere.'); + const sky = envelope('show_street_level', { + ok: false, + stage: 'imagery', + aim: 'sky', + atViewCentre: true, + label: 'the view centre', + providerName: 'Mapillary', + }); + assert.equal(sky.say, 'Aim the middle of the view at a street first.'); + assertSpeakable(sky.say); const armed = envelope('show_street_level', { ok: true, armed: true, From dd83812a21daeb83b3bedbd48e99d81d53d68195 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 02:20:57 +0100 Subject: [PATCH 037/181] 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 038/181] 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 039/181] 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 ?? '');
+