From 4fc813e1752b8e38f6051c6ddcaf0e3e3a97947a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:02 -0500 Subject: [PATCH 01/35] feat(agent): extract model-free World Packet boundary --- src/agent/worldPacket.js | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/agent/worldPacket.js diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..c492f3d6b --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,184 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshots, + viewStateLayerRow, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +const NON_SEMANTIC_PACKET_KEYS = new Set([ + 'note', + 'ageLabel', + 'generatedAt', +]); + +function semanticPacketValue(value) { + if (Array.isArray(value)) return value.map(semanticPacketValue); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const key of Object.keys(value).sort()) { + if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; + out[key] = semanticPacketValue(value[key]); + } + return out; +} + +/** + * Stable semantic fingerprint for change detection, not authentication. + * + * Presentation-only narration/age labels are excluded. Feed state, source, + * lastUpdate, camera, context, tracked entities, and other semantic state stay + * inside the digest. + */ +export function semanticWorldPacketFingerprint(packet) { + const canonical = JSON.stringify(semanticPacketValue(packet)); + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let i = 0; i < canonical.length; i += 1) { + hash ^= BigInt(canonical.charCodeAt(i)); + hash = BigInt.asUintN(64, hash * prime); + } + return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; +} + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), +} = {}) { + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + const rows = dataManager.getAll(); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + layers: rows.map((layer) => viewStateLayerRow(layer, { now })), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} From 5c044a3d8da61eb707c7b61e16e42d6c72fc2ad3 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:05 -0500 Subject: [PATCH 02/35] test(agent): prove World Packet parity and fingerprint semantics --- src/agent/worldPacket.test.mjs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/agent/worldPacket.test.mjs diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..9d9e0bd7d --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { + getWorldPacket, + semanticWorldPacketFingerprint, +} from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(voice, packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + + +test('semantic fingerprint ignores narration prose but changes with observed state', () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); + + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); +}); From 3c163d09a15f2a152cb6ce6de09ff0e82db1a63e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:08 -0500 Subject: [PATCH 03/35] refactor(voice): delegate world state to World Packet builder --- src/voice/gevActions.js | 73 ++++------------------------------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index cfdb1fb0b..cccae249e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -39,6 +39,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { getWorldPacket } from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -2424,25 +2425,6 @@ function runManagedVoiceNavigation( } /** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3073,53 +3055,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - return { - ok: true, - action: 'get_current_view_state', - camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - heightM: cartographic.height, - }, - style: styleManager.activeStyle || 'normal', - context: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - layers: dataManager.getAll().map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( From 6439575d158238ce92470106d221f5d450c6f29c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:11 -0500 Subject: [PATCH 04/35] feat(agent): export World Packet boundary --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e48881c6d..0a299b51d 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", - "./sources/map-features": "./src/sources/overpassFeatures.js" + "./sources/map-features": "./src/sources/overpassFeatures.js", + "./agent/world-packet": "./src/agent/worldPacket.js" } } From c55d4d7c25877abb1a6eec331d82ad0a124bf61e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:14 -0500 Subject: [PATCH 05/35] build: register World Packet package boundary --- scripts/package-boundaries.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..42e98d736 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,15 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "agent-world-packet": { + "exports": ["./agent/world-packet"], + "modules": [ + "src/agent/worldPacket.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/contextModePolicy.js" + ], + "external": ["cesium"] } } From 05b13e9a78b091d1426e5a27889ad274d4cb350b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:48 -0500 Subject: [PATCH 06/35] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 58b7d2d95e7ba3e323a5bd506946f4687df670a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:52:28 +0000 Subject: [PATCH 07/35] style: apply adopted formatting --- src/agent/worldPacket.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index c492f3d6b..e8c522caa 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -24,11 +24,7 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set([ - 'note', - 'ageLabel', - 'generatedAt', -]); +const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); function semanticPacketValue(value) { if (Array.isArray(value)) return value.map(semanticPacketValue); From 6a6703e0c6db8bca4ac9ab7aa27c6df931106f5c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:52:54 -0500 Subject: [PATCH 08/35] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 3c53d63defb180199a039787412e6aa811d43b2d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:55:09 -0500 Subject: [PATCH 09/35] fix: declare World Packet in application boundary --- scripts/package-boundaries.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 42e98d736..b362d28f4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1851,6 +1851,7 @@ "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", + "src/agent/worldPacket.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", From 8acb526d1fc28dbec508b5303f1865bc44c099f2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:57:17 -0500 Subject: [PATCH 10/35] fix: include World Packet feed-state dependencies --- scripts/package-boundaries.json | 746 ++++++++++++++++++++++++-------- 1 file changed, 567 insertions(+), 179 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b362d28f4..260179257 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,27 +12,50 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js", + "src/app/atmosphereCompat.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite", + "./build/html" + ], + "modules": [ + "build/vite.js", + "build/application-html.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -48,16 +71,24 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -75,13 +106,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -93,13 +130,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -107,35 +150,59 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-csv": { - "exports": ["./sources/firms-csv"], - "modules": ["src/data/firmsCsv.js"], + "exports": [ + "./sources/firms-csv" + ], + "modules": [ + "src/data/firmsCsv.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -145,13 +212,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -176,7 +249,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -192,7 +267,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -215,7 +292,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -234,7 +313,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -261,7 +342,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -289,7 +372,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -300,7 +385,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -321,17 +408,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -344,7 +441,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -353,12 +452,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -371,10 +477,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -384,7 +494,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -404,7 +517,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -415,7 +530,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -426,7 +543,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -439,7 +558,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -482,10 +604,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -494,7 +620,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -624,10 +752,14 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +789,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,7 +814,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -729,10 +865,14 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -743,7 +883,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -782,10 +924,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -820,10 +966,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,10 +995,14 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -869,20 +1023,28 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "recent-imagery": { - "exports": ["./layers/recent-imagery"], + "exports": [ + "./layers/recent-imagery" + ], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -891,10 +1053,14 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -914,10 +1080,14 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -939,10 +1109,15 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -966,10 +1141,14 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1001,10 +1180,16 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1028,10 +1213,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1069,10 +1258,16 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1091,10 +1286,14 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1132,10 +1331,15 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": ["hls.js", "cesium"] + "external": [ + "hls.js", + "cesium" + ] }, "alpr-cameras": { - "exports": ["./layers/alpr"], + "exports": [ + "./layers/alpr" + ], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1154,10 +1358,16 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1188,10 +1398,14 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1205,10 +1419,14 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1217,7 +1435,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1253,7 +1474,9 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -2042,12 +2265,18 @@ ] }, "application-catalog": { - "exports": ["./application/catalog"], - "modules": ["src/app/catalog.js"], + "exports": [ + "./application/catalog" + ], + "modules": [ + "src/app/catalog.js" + ], "external": [] }, "application-layer-construction": { - "exports": ["./application/layers"], + "exports": [ + "./application/layers" + ], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2526,7 +2755,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2587,22 +2818,33 @@ ] }, "layer-lifecycle": { - "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "exports": [ + "./data/lifecycle" + ], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js" + ], "external": [] }, "ui-navigation": { - "exports": ["./ui/navigation"], + "exports": [ + "./ui/navigation" + ], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-share-restoration": { - "exports": ["./ui/share-restoration"], + "exports": [ + "./ui/share-restoration" + ], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2615,7 +2857,9 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2640,10 +2884,14 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-panel-chrome": { - "exports": ["./ui/panel-chrome"], + "exports": [ + "./ui/panel-chrome" + ], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2662,7 +2910,9 @@ "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2673,7 +2923,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2681,7 +2933,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2692,7 +2946,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2700,7 +2956,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2708,7 +2966,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2716,37 +2976,68 @@ "external": [] }, "directions-layer": { - "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], - "external": ["cesium"] + "exports": [ + "./layers/directions" + ], + "modules": [ + "src/layers/directions/index.js", + "src/data/routeSteps.js" + ], + "external": [ + "cesium" + ] }, "radio-browser-source": { - "exports": ["./sources/radio-browser"], - "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "exports": [ + "./sources/radio-browser" + ], + "modules": [ + "src/sources/radioBrowser.js", + "src/data/radioCountry.js" + ], "external": [] }, "cctv-types-source": { - "exports": ["./sources/cctv-types"], - "modules": ["src/sources/cctvTypes.js"], + "exports": [ + "./sources/cctv-types" + ], + "modules": [ + "src/sources/cctvTypes.js" + ], "external": [] }, "regional-source": { - "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "exports": [ + "./sources/regional" + ], + "modules": [ + "src/data/regionalModel.js" + ], "external": [] }, "voice-action-schemas": { - "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], + "exports": [ + "./voice/action-schemas" + ], + "modules": [ + "src/voice/actionSchemas.js", + "src/hudLayoutPolicy.js" + ], "external": [] }, "voice-session": { - "exports": ["./voice/session"], - "modules": ["src/voice/session.js"], + "exports": [ + "./voice/session" + ], + "modules": [ + "src/voice/session.js" + ], "external": [] }, "voice-session-controls": { - "exports": ["./voice/session-controls"], + "exports": [ + "./voice/session-controls" + ], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -2755,7 +3046,9 @@ "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2766,20 +3059,34 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "bikeshare-source": { - "exports": ["./layers/bikeshare/source"], - "modules": ["src/layers/bikeshare/source.js"], + "exports": [ + "./layers/bikeshare/source" + ], + "modules": [ + "src/layers/bikeshare/source.js" + ], "external": [] }, "cctv-source": { - "exports": ["./layers/cctv/source"], - "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "exports": [ + "./layers/cctv/source" + ], + "modules": [ + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js" + ], "external": [] }, "earthquakes-source": { - "exports": ["./layers/earthquakes/source"], + "exports": [ + "./layers/earthquakes/source" + ], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -2787,12 +3094,18 @@ "external": [] }, "firms-source": { - "exports": ["./layers/firms/source"], - "modules": ["src/layers/firms/source.js"], + "exports": [ + "./layers/firms/source" + ], + "modules": [ + "src/layers/firms/source.js" + ], "external": [] }, "installations-source": { - "exports": ["./layers/installations/source"], + "exports": [ + "./layers/installations/source" + ], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -2809,25 +3122,43 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "launches-source": { - "exports": ["./layers/launches/source"], - "modules": ["src/layers/launches/source.js"], + "exports": [ + "./layers/launches/source" + ], + "modules": [ + "src/layers/launches/source.js" + ], "external": [] }, "radio-source": { - "exports": ["./layers/radio/source"], - "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "exports": [ + "./layers/radio/source" + ], + "modules": [ + "src/layers/radio/policy.js", + "src/layers/radio/source.js" + ], "external": [] }, "satellites-source": { - "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "exports": [ + "./layers/satellites/source" + ], + "modules": [ + "src/layers/satellites/source.js" + ], "external": [] }, "traffic-source-factory": { - "exports": ["./layers/traffic/source"], + "exports": [ + "./layers/traffic/source" + ], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -2843,10 +3174,16 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "reference-sources": { - "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "exports": [ + "./sources/reference", + "./layers/submarine-cables/source" + ], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -2859,7 +3196,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2872,17 +3211,27 @@ "external": [] }, "source-http-body": { - "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "exports": [ + "./sources/http-body" + ], + "modules": [ + "src/sources/httpBody.js" + ], "external": [] }, "overpass-lexer": { - "exports": ["./sources/overpass"], - "modules": ["src/sources/overpass.js"], + "exports": [ + "./sources/overpass" + ], + "modules": [ + "src/sources/overpass.js" + ], "external": [] }, "nominatim-source": { - "exports": ["./sources/nominatim"], + "exports": [ + "./sources/nominatim" + ], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -2894,7 +3243,9 @@ }, "transit-provider": { "runtime": "node", - "exports": ["./server/providers/transit"], + "exports": [ + "./server/providers/transit" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -2909,20 +3260,28 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-source": { - "exports": ["./sources/transit"], + "exports": [ + "./sources/transit" + ], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-standalone": { - "exports": ["./layers/transit/standalone"], + "exports": [ + "./layers/transit/standalone" + ], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -2981,10 +3340,16 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": ["cesium", "egm96-universal", "pbf"] + "external": [ + "cesium", + "egm96-universal", + "pbf" + ] }, "transit-layer": { - "exports": ["./layers/transit"], + "exports": [ + "./layers/transit" + ], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3018,10 +3383,15 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": ["cesium", "pbf"] + "external": [ + "cesium", + "pbf" + ] }, "transit-source-factory": { - "exports": ["./layers/transit/source"], + "exports": [ + "./layers/transit/source" + ], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3029,7 +3399,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3040,10 +3412,14 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "map-feature-source": { - "exports": ["./sources/map-features"], + "exports": [ + "./sources/map-features" + ], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3054,7 +3430,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3063,10 +3441,14 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "perimeters-source": { - "exports": ["./layers/perimeters/source"], + "exports": [ + "./layers/perimeters/source" + ], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3075,13 +3457,19 @@ "external": [] }, "agent-world-packet": { - "exports": ["./agent/world-packet"], + "exports": [ + "./agent/world-packet" + ], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", "src/data/feedState.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] } } From cbd3b13afb022cf1904dd08e809d58167f6e7340 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:58:20 -0500 Subject: [PATCH 11/35] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 914df05346d50adf13abd3337e0ab1d65daf19b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:59:00 +0000 Subject: [PATCH 12/35] style: apply adopted formatting --- scripts/package-boundaries.json | 742 ++++++++------------------------ 1 file changed, 178 insertions(+), 564 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 260179257..fa4f872a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,50 +12,27 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js", - "src/app/atmosphereCompat.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite", - "./build/html" - ], - "modules": [ - "build/vite.js", - "build/application-html.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite", "./build/html"], + "modules": ["build/vite.js", "build/application-html.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -71,24 +48,16 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -106,19 +75,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -130,19 +93,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -150,59 +107,35 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-csv": { - "exports": [ - "./sources/firms-csv" - ], - "modules": [ - "src/data/firmsCsv.js" - ], + "exports": ["./sources/firms-csv"], + "modules": ["src/data/firmsCsv.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -212,19 +145,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -249,9 +176,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -267,9 +192,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -292,9 +215,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -313,9 +234,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -342,9 +261,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -372,9 +289,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -385,9 +300,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -408,27 +321,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -441,9 +344,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -452,19 +353,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -477,14 +371,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -494,10 +384,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -517,9 +404,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -530,9 +415,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -543,9 +426,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -558,10 +439,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -604,14 +482,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -620,9 +494,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -752,14 +624,10 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -789,9 +657,7 @@ "external": [] }, "director": { - "exports": [ - "./director" - ], + "exports": ["./director"], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -814,9 +680,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -865,14 +729,10 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -883,9 +743,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -924,14 +782,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -966,14 +820,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -995,14 +845,10 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1023,28 +869,20 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "recent-imagery": { - "exports": [ - "./layers/recent-imagery" - ], + "exports": ["./layers/recent-imagery"], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -1053,14 +891,10 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -1080,14 +914,10 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -1109,15 +939,10 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -1141,14 +966,10 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1180,16 +1001,10 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1213,14 +1028,10 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1258,16 +1069,10 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1286,14 +1091,10 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1331,15 +1132,10 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": [ - "hls.js", - "cesium" - ] + "external": ["hls.js", "cesium"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1358,16 +1154,10 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "radio-layer": { - "exports": [ - "./layers/radio" - ], + "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1398,14 +1188,10 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "submarine-cables": { - "exports": [ - "./layers/submarine-cables" - ], + "exports": ["./layers/submarine-cables"], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1419,14 +1205,10 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "bundled-cables": { - "exports": [ - "./sources/bundled-cables" - ], + "exports": ["./sources/bundled-cables"], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1435,10 +1217,7 @@ "external": [] }, "geography": { - "exports": [ - "./geography/natural-earth", - "./geography/neighborhoods" - ], + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1474,9 +1253,7 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "voice-connection": { "exports": [ @@ -2265,18 +2042,12 @@ ] }, "application-catalog": { - "exports": [ - "./application/catalog" - ], - "modules": [ - "src/app/catalog.js" - ], + "exports": ["./application/catalog"], + "modules": ["src/app/catalog.js"], "external": [] }, "application-layer-construction": { - "exports": [ - "./application/layers" - ], + "exports": ["./application/layers"], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2755,9 +2526,7 @@ ] }, "application-operations": { - "exports": [ - "./application/operations" - ], + "exports": ["./application/operations"], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2818,33 +2587,22 @@ ] }, "layer-lifecycle": { - "exports": [ - "./data/lifecycle" - ], - "modules": [ - "src/data/lifecycle.js", - "src/data/cameraArrival.js" - ], + "exports": ["./data/lifecycle"], + "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], "external": [] }, "ui-navigation": { - "exports": [ - "./ui/navigation" - ], + "exports": ["./ui/navigation"], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-share-restoration": { - "exports": [ - "./ui/share-restoration" - ], + "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2857,9 +2615,7 @@ "external": [] }, "ui-visual-settings": { - "exports": [ - "./ui/visual-settings" - ], + "exports": ["./ui/visual-settings"], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2884,14 +2640,10 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-panel-chrome": { - "exports": [ - "./ui/panel-chrome" - ], + "exports": ["./ui/panel-chrome"], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2910,9 +2662,7 @@ "external": [] }, "civil-flight-records": { - "exports": [ - "./layers/flights/records" - ], + "exports": ["./layers/flights/records"], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2923,9 +2673,7 @@ "external": [] }, "civil-flight-ingestion": { - "exports": [ - "./layers/flights/ingestion" - ], + "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2933,9 +2681,7 @@ "external": [] }, "military-flight-records": { - "exports": [ - "./layers/military/records" - ], + "exports": ["./layers/military/records"], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2946,9 +2692,7 @@ "external": [] }, "military-flight-ingestion": { - "exports": [ - "./layers/military/ingestion" - ], + "exports": ["./layers/military/ingestion"], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2956,9 +2700,7 @@ "external": [] }, "vessel-records": { - "exports": [ - "./layers/vessels/records" - ], + "exports": ["./layers/vessels/records"], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2966,9 +2708,7 @@ "external": [] }, "vessel-ingestion": { - "exports": [ - "./layers/vessels/ingestion" - ], + "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2976,68 +2716,37 @@ "external": [] }, "directions-layer": { - "exports": [ - "./layers/directions" - ], - "modules": [ - "src/layers/directions/index.js", - "src/data/routeSteps.js" - ], - "external": [ - "cesium" - ] + "exports": ["./layers/directions"], + "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], + "external": ["cesium"] }, "radio-browser-source": { - "exports": [ - "./sources/radio-browser" - ], - "modules": [ - "src/sources/radioBrowser.js", - "src/data/radioCountry.js" - ], + "exports": ["./sources/radio-browser"], + "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], "external": [] }, "cctv-types-source": { - "exports": [ - "./sources/cctv-types" - ], - "modules": [ - "src/sources/cctvTypes.js" - ], + "exports": ["./sources/cctv-types"], + "modules": ["src/sources/cctvTypes.js"], "external": [] }, "regional-source": { - "exports": [ - "./sources/regional" - ], - "modules": [ - "src/data/regionalModel.js" - ], + "exports": ["./sources/regional"], + "modules": ["src/data/regionalModel.js"], "external": [] }, "voice-action-schemas": { - "exports": [ - "./voice/action-schemas" - ], - "modules": [ - "src/voice/actionSchemas.js", - "src/hudLayoutPolicy.js" - ], + "exports": ["./voice/action-schemas"], + "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], "external": [] }, "voice-session": { - "exports": [ - "./voice/session" - ], - "modules": [ - "src/voice/session.js" - ], + "exports": ["./voice/session"], + "modules": ["src/voice/session.js"], "external": [] }, "voice-session-controls": { - "exports": [ - "./voice/session-controls" - ], + "exports": ["./voice/session-controls"], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -3046,9 +2755,7 @@ "external": [] }, "alpr-source": { - "exports": [ - "./layers/alpr/source" - ], + "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -3059,34 +2766,20 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "bikeshare-source": { - "exports": [ - "./layers/bikeshare/source" - ], - "modules": [ - "src/layers/bikeshare/source.js" - ], + "exports": ["./layers/bikeshare/source"], + "modules": ["src/layers/bikeshare/source.js"], "external": [] }, "cctv-source": { - "exports": [ - "./layers/cctv/source" - ], - "modules": [ - "src/layers/cctv/source.js", - "src/layers/cctv/sourcePolicy.js" - ], + "exports": ["./layers/cctv/source"], + "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], "external": [] }, "earthquakes-source": { - "exports": [ - "./layers/earthquakes/source" - ], + "exports": ["./layers/earthquakes/source"], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -3094,18 +2787,12 @@ "external": [] }, "firms-source": { - "exports": [ - "./layers/firms/source" - ], - "modules": [ - "src/layers/firms/source.js" - ], + "exports": ["./layers/firms/source"], + "modules": ["src/layers/firms/source.js"], "external": [] }, "installations-source": { - "exports": [ - "./layers/installations/source" - ], + "exports": ["./layers/installations/source"], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -3122,43 +2809,25 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "launches-source": { - "exports": [ - "./layers/launches/source" - ], - "modules": [ - "src/layers/launches/source.js" - ], + "exports": ["./layers/launches/source"], + "modules": ["src/layers/launches/source.js"], "external": [] }, "radio-source": { - "exports": [ - "./layers/radio/source" - ], - "modules": [ - "src/layers/radio/policy.js", - "src/layers/radio/source.js" - ], + "exports": ["./layers/radio/source"], + "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], "external": [] }, "satellites-source": { - "exports": [ - "./layers/satellites/source" - ], - "modules": [ - "src/layers/satellites/source.js" - ], + "exports": ["./layers/satellites/source"], + "modules": ["src/layers/satellites/source.js"], "external": [] }, "traffic-source-factory": { - "exports": [ - "./layers/traffic/source" - ], + "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -3174,16 +2843,10 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "reference-sources": { - "exports": [ - "./sources/reference", - "./layers/submarine-cables/source" - ], + "exports": ["./sources/reference", "./layers/submarine-cables/source"], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -3196,9 +2859,7 @@ "external": [] }, "nominatim-provider": { - "exports": [ - "./search/nominatim" - ], + "exports": ["./search/nominatim"], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3211,27 +2872,17 @@ "external": [] }, "source-http-body": { - "exports": [ - "./sources/http-body" - ], - "modules": [ - "src/sources/httpBody.js" - ], + "exports": ["./sources/http-body"], + "modules": ["src/sources/httpBody.js"], "external": [] }, "overpass-lexer": { - "exports": [ - "./sources/overpass" - ], - "modules": [ - "src/sources/overpass.js" - ], + "exports": ["./sources/overpass"], + "modules": ["src/sources/overpass.js"], "external": [] }, "nominatim-source": { - "exports": [ - "./sources/nominatim" - ], + "exports": ["./sources/nominatim"], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -3243,9 +2894,7 @@ }, "transit-provider": { "runtime": "node", - "exports": [ - "./server/providers/transit" - ], + "exports": ["./server/providers/transit"], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -3260,28 +2909,20 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-source": { - "exports": [ - "./sources/transit" - ], + "exports": ["./sources/transit"], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-standalone": { - "exports": [ - "./layers/transit/standalone" - ], + "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -3340,16 +2981,10 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": [ - "cesium", - "egm96-universal", - "pbf" - ] + "external": ["cesium", "egm96-universal", "pbf"] }, "transit-layer": { - "exports": [ - "./layers/transit" - ], + "exports": ["./layers/transit"], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3383,15 +3018,10 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": [ - "cesium", - "pbf" - ] + "external": ["cesium", "pbf"] }, "transit-source-factory": { - "exports": [ - "./layers/transit/source" - ], + "exports": ["./layers/transit/source"], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3399,9 +3029,7 @@ "external": [] }, "transit-service": { - "exports": [ - "./sources/transit-service" - ], + "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3412,14 +3040,10 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "map-feature-source": { - "exports": [ - "./sources/map-features" - ], + "exports": ["./sources/map-features"], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3430,9 +3054,7 @@ "external": [] }, "perimeters": { - "exports": [ - "./layers/perimeters" - ], + "exports": ["./layers/perimeters"], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3441,14 +3063,10 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "perimeters-source": { - "exports": [ - "./layers/perimeters/source" - ], + "exports": ["./layers/perimeters/source"], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3457,9 +3075,7 @@ "external": [] }, "agent-world-packet": { - "exports": [ - "./agent/world-packet" - ], + "exports": ["./agent/world-packet"], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", @@ -3468,8 +3084,6 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } From abea3f2feb99c14a64e5c1884292411b044a0bb2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:59:48 -0500 Subject: [PATCH 13/35] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From a194da21dd6f1fdb4d41170e560e759f5244cf16 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:08 -0500 Subject: [PATCH 14/35] chore: add one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml new file mode 100644 index 000000000..40b244d2a --- /dev/null +++ b/.github/workflows/downstream-format-fix.yml @@ -0,0 +1,37 @@ +name: Downstream format fix + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@v5 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - run: npm run format + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: apply adopted formatting" + git push From 2b7f832f456c933811c8dc0faa75caf111816718 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:50 -0500 Subject: [PATCH 15/35] chore: remove one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 --------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml deleted file mode 100644 index 40b244d2a..000000000 --- a/.github/workflows/downstream-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Downstream format fix - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@v5 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - - run: npm run format - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: apply adopted formatting" - git push From 2110d06e36eb7b7ed0a6cf595257c414ecde2587 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:45 -0700 Subject: [PATCH 16/35] feat(tools): count and summarize every match in the area tools aircraft_in_area and vessels_in_area return at most 200 rows, so a client asked which operators dominate an area, or the average altitude there, could only estimate from a truncated sample. Both tools now take an optional group_by (aircraft: operator, origin_country, type_code; ships: type, destination) and stats (airborne altitude and speed for aircraft, speed for ships). Each is computed over every match before rows are capped. Breakdowns list the ten largest groups plus other and unknown counts, and the summary names the three largest. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 6 ++- docs/TOOLS.md | 4 +- scripts/format-scope.json | 1 + src/tools/queries/aviation.js | 37 +++++++++++++- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++ src/tools/queries/maritime.js | 22 +++++++- src/tools/queries/maritime.test.mjs | 25 +++++++++ src/tools/results.js | 69 +++++++++++++++++++++++++ src/tools/results.test.mjs | 78 +++++++++++++++++++++++++++++ 10 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 src/tools/results.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..0a5f41549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a + client can ask which operators or ship types dominate an area, or the average + altitude or speed, over every match instead of estimating from the returned + rows. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 274294b5d..520bb9207 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -93,7 +93,11 @@ perimeters, terrain height, military installations and map features, plus a combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap -lists at 25 rows by default. Voice offers the same queries next to its app +lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also +count every match by a field (`group_by`: operator, country or type for +aircraft; type or destination for ships; the ten largest groups, then other and +unknown counts) and summarize airborne altitude and speed (`stats`: count, min, +median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 401058d91..f3b86f7a3 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -263,11 +263,11 @@ and the development server's internal routes. See SECURITY.md. | `get_earthquakes` | `earthquakes` | USGS M2.5+ events in the last 24 hours, strongest first | | `get_active_fires` | `fires` | NASA FIRMS detections in an area, highest radiative power first | | `get_recent_launches` | `launches` | Launch Library 2 launches in the last 30 days, newest first | -| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed | +| `aircraft_in_area` | `aircraft` | Aircraft in an area, nearest first; `military: true` reads the `military` feed; `group_by` and `stats` count and summarize every match | | `find_aircraft` | `aircraft` | Aircraft anywhere by callsign, ICAO address or registration | | `get_aircraft_track` | `aircraft` | Recent positions of one aircraft, thinned to 200 points | | `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb | -| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type | +| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match | | `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name | | `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points | | `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..f8c056177 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -941,6 +941,7 @@ "scripts/qa-context-arrivals.mjs", "src/tools/catalog.test.mjs", "src/tools/area.test.mjs", + "src/tools/results.test.mjs", "src/tools/queries/queries.test.mjs", "src/tools/mcp/mcp.test.mjs", "src/tools/localServer.test.mjs", diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..e3aca82c4 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -11,9 +11,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -32,6 +35,11 @@ const MILITARY_SCHEMA = Object.freeze({ description: 'Use the military aircraft feed instead of the general feed.', }); const MAX_TRACK_POINTS = 200; +const GROUP_LABELS = Object.freeze({ + operator: 'operator', + origin_country: 'country', + type_code: 'type', +}); const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -82,7 +90,10 @@ export const aircraftInArea = defineTool({ title: 'Aircraft in an area', description: 'Aircraft currently reported inside an area, nearest the center first. ' + - 'Optionally military aircraft only, airborne only, or within an altitude band.', + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + + 'group_by counts every matched aircraft by operator, country or type, and ' + + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + + 'just the rows returned.', inputSchema: { type: 'object', properties: { @@ -91,6 +102,16 @@ export const aircraftInArea = defineTool({ airborne_only: { type: 'boolean' }, min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 }, + group_by: { + type: 'string', + enum: Object.keys(GROUP_LABELS), + description: 'Count matched aircraft by this field, largest first.', + }, + stats: { + type: 'boolean', + description: + 'Summarize airborne altitude and speed over matched aircraft.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -126,9 +147,11 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${rows.length} ${kind} in ${area.label}` + + (groups ? breakdownPhrase(groups, GROUP_LABELS[args.group_by]) : '') + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { @@ -136,6 +159,18 @@ export const aircraftInArea = defineTool({ layers: [args.military ? 'military' : 'flights'], }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { + stats: { + altitude_m: numericSummary( + rows.filter((row) => !row.on_ground), + 'altitude_m', + ), + speed_mps: numericSummary(rows, 'speed_mps'), + }, + } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..618085b00 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,60 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('aircraft in an area can be counted by operator over every match', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { operator: 'United' }), + record('a2', 37.61, -122.3, { operator: 'Delta' }), + record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }), + record('a4', 37.63, -122.3, { onGround: true, baroAltitudeM: 0 }), + record('a5', 51.5, -0.1, { operator: 'British Airways' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'operator', + stats: true, + limit: 1, + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + ); + assert.equal(result.data.returned, 1); + assert.deepEqual(result.data.breakdown, { + field: 'operator', + groups: [ + { value: 'United', count: 2 }, + { value: 'Delta', count: 1 }, + ], + other_count: 0, + unknown_count: 1, + }); + // Altitude covers airborne aircraft only; speed covers every match. + assert.deepEqual(result.data.stats.altitude_m, { + count: 3, + min: 3000, + median: 10000, + mean: 7666.7, + max: 10000, + }); + assert.equal(result.data.stats.speed_mps.count, 4); +}); + +test('aircraft answers carry no breakdown or stats unless asked', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil) }, + }); + const result = await catalog.call('aircraft_in_area', { area: bay }); + assert.equal('breakdown' in result.data, false); + assert.equal('stats' in result.data, false); + await assert.rejects( + catalog.call('aircraft_in_area', { area: bay, group_by: 'callsign' }), + /must be one of/, + ); +}); diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5b64f2826..a918c1f52 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -12,9 +12,12 @@ import { } from '../area.js'; import { LIMIT_SCHEMA, + breakdown, + breakdownPhrase, capRows, countNoun, isoTime, + numericSummary, thinEvenly, } from '../results.js'; @@ -88,7 +91,9 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type.', + 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'ship by type or destination, and stats summarizes their speed; both ' + + 'cover all matches, not just the rows returned.', inputSchema: { type: 'object', properties: { @@ -99,6 +104,15 @@ export const vesselsInArea = defineTool({ maxLength: 40, description: 'Vessel type to match, such as "tanker" or "cargo".', }, + group_by: { + type: 'string', + enum: ['type', 'destination'], + description: 'Count matched vessels by this field, largest first.', + }, + stats: { + type: 'boolean', + description: 'Summarize speed over matched vessels.', + }, limit: LIMIT_SCHEMA, }, required: ['area'], @@ -119,13 +133,19 @@ export const vesselsInArea = defineTool({ .map((record) => vesselRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); const kind = args.type ? `${args.type} vessel` : 'vessel'; + const groups = args.group_by ? breakdown(rows, args.group_by) : null; return { summary: `${countNoun(rows.length, kind)} in ${area.label}` + + (groups ? breakdownPhrase(groups, args.group_by) : '') + (snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'), data: { view: suggestView(services, { area, layers: ['ais-live-vessels'] }), ...capRows(rows, args.limit), + ...(groups ? { breakdown: groups } : {}), + ...(args.stats + ? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } } + : {}), ...snapshotInfo(snapshot), }, }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 785e38223..d1c43e60a 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -224,3 +224,28 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('lat'), false); assert.equal(lookup.has('radius_km'), false); }); + +test('vessels in an area can be counted by destination with a speed summary', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: vessels() }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'destination', + stats: true, + }); + assert.equal( + result.summary, + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + ); + assert.deepEqual(result.data.breakdown, { + field: 'destination', + groups: [{ value: 'OAKLAND', count: 1 }], + other_count: 0, + unknown_count: 1, + }); + assert.deepEqual(result.data.stats, { + speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, + }); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..b4a24c72a 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -22,6 +22,75 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { }; } +/** Most groups a breakdown lists before counting the rest as other. */ +export const MAX_GROUPS = 10; + +/** + * Count rows by one text field over every row, not just those returned. + * Values group case-insensitively and keep their first spelling; rows + * without a value are counted as unknown. + */ +export function breakdown(rows, field) { + const groups = new Map(); + let unknown = 0; + for (const row of rows) { + const value = typeof row[field] === 'string' ? row[field].trim() : ''; + if (!value) { + unknown += 1; + continue; + } + const key = value.toLowerCase(); + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { value, count: 1 }); + } + const sorted = [...groups.values()].sort( + (a, b) => b.count - a.count || a.value.localeCompare(b.value), + ); + const listed = sorted.slice(0, MAX_GROUPS); + return { + field, + groups: listed, + other_count: rows.length - unknown - sum(listed.map((g) => g.count)), + unknown_count: unknown, + }; +} + +/** Minimum, median, mean and maximum of one numeric field, or null. */ +export function numericSummary(rows, field) { + const values = rows + .map((row) => row[field]) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!values.length) return null; + const middle = values.length >> 1; + const median = + values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2; + const round1 = (value) => Number(value.toFixed(1)); + return { + count: values.length, + min: values[0], + median: round1(median), + mean: round1(sum(values) / values.length), + max: values.at(-1), + }; +} + +/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +export function breakdownPhrase(result, label) { + if (!result.groups.length) return ''; + const top = result.groups + .slice(0, 3) + .map((group) => `${group.value} (${group.count})`); + return `; most by ${label}: ${top.join(', ')}`; +} + +function sum(values) { + return values.reduce((total, value) => total + value, 0); +} + /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { return Number.isFinite(ms) ? new Date(ms).toISOString() : null; diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs new file mode 100644 index 000000000..ba67b9f64 --- /dev/null +++ b/src/tools/results.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MAX_GROUPS, + breakdown, + breakdownPhrase, + numericSummary, +} from './results.js'; + +test('a breakdown counts every row, case-insensitively, largest first', () => { + const rows = [ + { operator: 'Delta' }, + { operator: 'United' }, + { operator: ' delta ' }, + { operator: null }, + { operator: '' }, + { operator: 'American' }, + { operator: 'United' }, + { operator: 'DELTA' }, + ]; + assert.deepEqual(breakdown(rows, 'operator'), { + field: 'operator', + groups: [ + { value: 'Delta', count: 3 }, + { value: 'United', count: 2 }, + { value: 'American', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); +}); + +test('groups past the limit are counted as other', () => { + const rows = Array.from({ length: MAX_GROUPS + 3 }, (_, index) => ({ + type: `T${String(index).padStart(2, '0')}`, + })); + rows.push({ type: 'T00' }); + const result = breakdown(rows, 'type'); + assert.equal(result.groups.length, MAX_GROUPS); + assert.deepEqual(result.groups[0], { value: 'T00', count: 2 }); + assert.equal(result.other_count, 3); + assert.equal( + result.groups.reduce((total, group) => total + group.count, 0) + + result.other_count + + result.unknown_count, + rows.length, + ); +}); + +test('a numeric summary skips missing values', () => { + const rows = [{ v: 4 }, { v: null }, { v: 1 }, { v: 10 }, { v: 3 }, {}]; + assert.deepEqual(numericSummary(rows, 'v'), { + count: 4, + min: 1, + median: 3.5, + mean: 4.5, + max: 10, + }); + assert.equal(numericSummary([{ v: 2 }, { v: 7 }, { v: 5 }], 'v').median, 5); + assert.equal(numericSummary([{ v: null }], 'v'), null); + assert.equal(numericSummary([], 'v'), null); +}); + +test('the breakdown phrase names the three largest groups', () => { + const result = { + groups: [ + { value: 'A', count: 4 }, + { value: 'B', count: 3 }, + { value: 'C', count: 2 }, + { value: 'D', count: 1 }, + ], + }; + assert.equal( + breakdownPhrase(result, 'type'), + '; most by type: A (4), B (3), C (2)', + ); + assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); +}); From 603f428397f61ddd06893b3178081398f5f9c50c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:35:24 -0700 Subject: [PATCH 17/35] fix(tools): say when a breakdown's field is missing from the feed OpenSky, the default civil feed, reports no operator or type code, so a breakdown by either put every aircraft under unknown while the summary said nothing. The summary now says "no operator reported" in that case, and otherwise appends the unknown count after the largest groups. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 +++-- src/tools/queries/aviation.js | 4 +++- src/tools/queries/aviation.test.mjs | 2 +- src/tools/queries/maritime.test.mjs | 2 +- src/tools/results.js | 13 ++++++++++--- src/tools/results.test.mjs | 12 +++++++++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 520bb9207..f9e9c02a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -96,8 +96,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts) and summarize airborne altitude and speed (`stats`: count, min, -median, mean, max). Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +unknown counts; the summary says when a feed reports none of the field) and +summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +Both cover all matches, not just the rows returned. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index e3aca82c4..23652f211 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -105,7 +105,9 @@ export const aircraftInArea = defineTool({ group_by: { type: 'string', enum: Object.keys(GROUP_LABELS), - description: 'Count matched aircraft by this field, largest first.', + description: + 'Count matched aircraft by this field, largest first. Feeds that do not ' + + 'report the field count those aircraft as unknown.', }, stats: { type: 'boolean', diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 618085b00..b6b6e3497 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -409,7 +409,7 @@ test('aircraft in an area can be counted by operator over every match', async () }); assert.equal( result.summary, - '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1).', + '4 aircraft in 60 km around 37.600, -122.300; most by operator: United (2), Delta (1); 1 unknown.', ); assert.equal(result.data.returned, 1); assert.deepEqual(result.data.breakdown, { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d1c43e60a..662e5bdf2 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -237,7 +237,7 @@ test('vessels in an area can be counted by destination with a speed summary', as }); assert.equal( result.summary, - '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1).', + '2 vessels in 20 km around 37.780, -122.400; most by destination: OAKLAND (1); 1 unknown.', ); assert.deepEqual(result.data.breakdown, { field: 'destination', diff --git a/src/tools/results.js b/src/tools/results.js index b4a24c72a..528f59ea5 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -78,13 +78,20 @@ export function numericSummary(rows, field) { }; } -/** "; most by operator: Delta (12), United (9), American (7)" or "". */ +/** + * "; most by operator: Delta (12), United (9), American (7); 4 unknown", or + * "; no operator reported" when no row carries the field. + */ export function breakdownPhrase(result, label) { - if (!result.groups.length) return ''; + if (!result.groups.length) + return result.unknown_count ? `; no ${label} reported` : ''; const top = result.groups .slice(0, 3) .map((group) => `${group.value} (${group.count})`); - return `; most by ${label}: ${top.join(', ')}`; + const unknown = result.unknown_count + ? `; ${result.unknown_count} unknown` + : ''; + return `; most by ${label}: ${top.join(', ')}${unknown}`; } function sum(values) { diff --git a/src/tools/results.test.mjs b/src/tools/results.test.mjs index ba67b9f64..2e480afcd 100644 --- a/src/tools/results.test.mjs +++ b/src/tools/results.test.mjs @@ -63,6 +63,7 @@ test('a numeric summary skips missing values', () => { test('the breakdown phrase names the three largest groups', () => { const result = { + unknown_count: 0, groups: [ { value: 'A', count: 4 }, { value: 'B', count: 3 }, @@ -74,5 +75,14 @@ test('the breakdown phrase names the three largest groups', () => { breakdownPhrase(result, 'type'), '; most by type: A (4), B (3), C (2)', ); - assert.equal(breakdownPhrase({ groups: [] }, 'type'), ''); + assert.equal( + breakdownPhrase({ ...result, unknown_count: 5 }, 'type'), + '; most by type: A (4), B (3), C (2); 5 unknown', + ); + // A feed that never reports the field says so instead of staying silent. + assert.equal( + breakdownPhrase({ groups: [], unknown_count: 236 }, 'operator'), + '; no operator reported', + ); + assert.equal(breakdownPhrase({ groups: [], unknown_count: 0 }, 'type'), ''); }); From 1371a867137aebcdcd488213a0b2ce7b3e42bfa9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:11:47 -0700 Subject: [PATCH 18/35] docs: say the area breakdowns reach MCP clients only Voice leaves aircraft_in_area and vessels_in_area out, so the changelog no longer suggests spoken questions gain breakdowns. It also stops leading with operators, which the default civil feed never reports. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++---- docs/CURRENT-STATE.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f41549..b918e89cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so a - client can ask which operators or ship types dominate an area, or the average - altitude or speed, over every match instead of estimating from the returned - rows. +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9e9c02a7..bdc9573b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,7 +98,8 @@ count every match by a field (`group_by`: operator, country or type for aircraft; type or destination for ships; the ten largest groups, then other and unknown counts; the summary says when a feed reports none of the field) and summarize airborne altitude and speed (`stats`: count, min, median, mean, max). -Both cover all matches, not just the rows returned. Voice offers the same queries next to its app +Both cover all matches, not just the rows returned; voice leaves these two +tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). From 52863a9f422c4120b6533339b770b8ca7995121c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:10 -0700 Subject: [PATCH 19/35] fix(tools): count AIS ship type 0 as unknown in vessel breakdowns AIS type 0 means "not available" (ITU-R M.1371), but the source keeps it as '0', so group_by: 'type' listed "0" as a type and understated unknown_count. vessels_in_area rows now report it as null, and a regression fixture mixes type 0 with Cargo and Tanker. Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.js | 8 ++++++- src/tools/queries/maritime.test.mjs | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index a918c1f52..b5bdc4afe 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -32,6 +32,12 @@ const ALL_VESSELS = 1_000_000; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const text = (value) => (typeof value === 'string' && value ? value : null); +// AIS ship type 0 is "not available" (ITU-R M.1371); the source keeps it as +// '0', so it is reported as unknown rather than counted as a type. +const vesselType = (value) => { + const type = text(value); + return type === '0' ? null : type; +}; function vesselRow(record, from) { const point = { lat: record.latitude, lon: record.longitude }; @@ -39,7 +45,7 @@ function vesselRow(record, from) { mmsi: record.id, name: text(record.name), imo: text(record.imo), - type: text(record.type), + type: vesselType(record.type), destination: text(record.destination), lat: round(record.latitude, 5), lon: round(record.longitude, 5), diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 662e5bdf2..c5a5ab21f 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -225,6 +225,41 @@ test('an area search names its area to the server; a vessel lookup asks for ever assert.equal(lookup.has('radius_km'), false); }); +test('AIS type 0 (not available) is counted as unknown, not as a type', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { type: '0' }), + vessel('366000002', 37.79, -122.41, { type: '0' }), + vessel('366000003', 37.77, -122.39, { type: 'Cargo' }), + vessel('366000004', 37.76, -122.38, { type: 'Tanker' }), + ], + }), + }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.deepEqual(result.data.breakdown, { + field: 'type', + groups: [ + { value: 'Cargo', count: 1 }, + { value: 'Tanker', count: 1 }, + ], + other_count: 0, + unknown_count: 2, + }); + assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.doesNotMatch(result.summary, /0 \(/); + assert.equal( + result.data.rows.find((row) => row.mmsi === '366000001').type, + null, + ); +}); + test('vessels in an area can be counted by destination with a speed summary', async () => { const catalog = composeCatalog({ tools: coreTools, From 60444cca53af98c21713d2fe4825efc3d20b69c2 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:06:24 -0700 Subject: [PATCH 20/35] style: format the AIS type 0 regression test Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/maritime.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index c5a5ab21f..f1d09bccb 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -252,7 +252,10 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async () other_count: 0, unknown_count: 2, }); - assert.match(result.summary, /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./); + assert.match( + result.summary, + /most by type: Cargo \(1\), Tanker \(1\); 2 unknown\./, + ); assert.doesNotMatch(result.summary, /0 \(/); assert.equal( result.data.rows.find((row) => row.mmsi === '366000001').type, From 8f6671b6c271abc9464ac990259a03db6943695c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:22:39 -0700 Subject: [PATCH 21/35] Merge upstream main into feat/tools-area-breakdowns Move the changelog entry into changelog.d/1056-area-tool-breakdowns.md (upstream's one-file-per-PR rule, #787) and leave CHANGELOG.md as on main. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ----- changelog.d/1056-area-tool-breakdowns.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1056-area-tool-breakdowns.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 22dce6a93..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md new file mode 100644 index 000000000..75f713907 --- /dev/null +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -0,0 +1,6 @@ +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which countries, aircraft types or ship types dominate an + area, or the average altitude or speed, over every match instead of + estimating from the returned rows. Operator and type are counted only where + the feed reports them; AIS ship type 0 ("not available") counts as unknown + (quanticsoul4772, #1056). From 3e23b4cd39182cdc5ccafad431078d1d8dc314d5 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:08:15 -0500 Subject: [PATCH 22/35] style(agent): wrap long test declarations to Prettier policy --- src/agent/worldPacket.test.mjs | 152 ++++++++++++++++++--------------- 1 file changed, 82 insertions(+), 70 deletions(-) diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index 76c41400a..48ce2b47d 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -71,82 +71,94 @@ test('World Packet preserves get_current_view_state parity', async () => { assert.equal(packet.feedProvenance.overall, 'nominal'); }); -test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { - const { viewer, dataManager, styleManager } = harness(); - dataManager.getAll = () => [ - { - id: 'traffic', - name: 'Traffic', - enabled: true, - stats: { - count: 0, - source: 'fixture', - error: 'fixture unavailable', - lastUpdate: null, +test( + 'World Packet keeps stale/unavailable feed semantics distinct from empty', + () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, }, - }, - ]; + ]; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - assert.equal(packet.layers[0].count, 0); - assert.notEqual(packet.layers[0].feedState, 'nominal'); - assert.notEqual(packet.feedProvenance.overall, 'nominal'); - assert.match(packet.feedProvenance.note, /fixture unavailable/i); -}); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); + }, +); -test('semantic fingerprint ignores narration prose but changes with observed state', () => { - const { viewer, dataManager, styleManager } = harness(); - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - const original = semanticWorldPacketFingerprint(packet); +test( + 'semantic fingerprint ignores narration prose but changes with observed state', + () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); - const proseOnly = structuredClone(packet); - proseOnly.feedProvenance.note = 'different generated narration'; - assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); - const changed = structuredClone(packet); - changed.layers[0].count += 1; - assert.notEqual(semanticWorldPacketFingerprint(changed), original); -}); + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); + }, +); // The 2026-10 main voice contract omits disabled layers from the model payload. -test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { - const { viewer, dataManager, styleManager } = harness(); - const rows = dataManager.getAll(); - rows.push({ - id: 'earthquakes', - name: 'Earthquakes', - enabled: false, - stats: { count: 99, source: 'fixture', error: 'stale' }, - }); - let reads = 0; - dataManager.getAll = () => { - reads += 1; - return rows; - }; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); - assert.equal(packet.disabledLayerCount, 1); - assert.deepEqual(packet.layers.map((layer) => layer.id), ['traffic']); - assert.equal(packet.feedProvenance.overall, 'nominal'); +test( + 'World Packet and voice preserve enabled-only payload and count disabled layers', + async () => { + const { viewer, dataManager, styleManager } = harness(); + const rows = dataManager.getAll(); + rows.push({ + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }); + let reads = 0; + dataManager.getAll = () => { + reads += 1; + return rows; + }; + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); + assert.equal(packet.disabledLayerCount, 1); + assert.deepEqual(packet.layers.map((layer) => layer.id), ['traffic']); + assert.equal(packet.feedProvenance.overall, 'nominal'); - const runner = createGevActionRunner({ viewer, dataManager, styleManager }); - const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); -}); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + assert.deepEqual(voice, packet); + }, +); -test('World Packet fingerprints distinguish enabled state but ignore narrative churn', () => { - const { viewer, dataManager, styleManager } = harness(); - const first = getWorldPacket({ viewer, dataManager, styleManager }); - const prose = structuredClone(first); - prose.feedProvenance.note = 'new narration only'; - assert.equal( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(prose), - ); - const hidden = structuredClone(first); - hidden.disabledLayerCount += 1; - assert.notEqual( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(hidden), - ); -}); +test( + 'World Packet fingerprints distinguish enabled state but ignore narrative churn', + () => { + const { viewer, dataManager, styleManager } = harness(); + const first = getWorldPacket({ viewer, dataManager, styleManager }); + const prose = structuredClone(first); + prose.feedProvenance.note = 'new narration only'; + assert.equal( + semanticWorldPacketFingerprint(first), + semanticWorldPacketFingerprint(prose), + ); + const hidden = structuredClone(first); + hidden.disabledLayerCount += 1; + assert.notEqual( + semanticWorldPacketFingerprint(first), + semanticWorldPacketFingerprint(hidden), + ); + }, +); From f4a4df5044def4392d27e3ecc344be24b4be7883 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:10:37 -0500 Subject: [PATCH 23/35] test(agent): retrieve exact pinned formatter output for #880 (temporary) --- scripts/format.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/format.mjs b/scripts/format.mjs index 25311ba29..1ba08abc1 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -141,6 +141,20 @@ if (import.meta.url === invoked) { if (mode === '--check' && result.changed.length) { for (const name of result.changed) console.error(`Needs formatting: ${name}`); + // Temporary formatter output probe for the existing #880 test only. + if (result.changed.includes('src/agent/worldPacket.test.mjs')) { + const file = path.join( + fileURLToPath(new URL('../', import.meta.url)), + 'src/agent/worldPacket.test.mjs', + ); + const formatted = await prettier.format(await readFile(file, 'utf8'), { + ...(await prettier.resolveConfig(file)), + filepath: file, + }); + const encoded = Buffer.from(formatted).toString('base64'); + for (let offset = 0; offset < encoded.length; offset += 1000) + console.error(`PRETTIER-DATA|src/agent/worldPacket.test.mjs|${offset}|${encoded.slice(offset, offset + 1000)}`); + } process.exitCode = 1; } else { console.log( From e4f526415d2d167a26683a5fa2f64d1fc1f5f7e6 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:12:15 -0500 Subject: [PATCH 24/35] style(agent): apply pinned Prettier output and remove temporary probe Restore the main branch formatter unchanged and use byte-exact Prettier 3.9.6 formatting on the World Packet parity regressions. --- scripts/format.mjs | 14 --- src/agent/worldPacket.test.mjs | 155 ++++++++++++++++----------------- 2 files changed, 73 insertions(+), 96 deletions(-) diff --git a/scripts/format.mjs b/scripts/format.mjs index 1ba08abc1..25311ba29 100644 --- a/scripts/format.mjs +++ b/scripts/format.mjs @@ -141,20 +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 formatter output probe for the existing #880 test only. - if (result.changed.includes('src/agent/worldPacket.test.mjs')) { - const file = path.join( - fileURLToPath(new URL('../', import.meta.url)), - 'src/agent/worldPacket.test.mjs', - ); - const formatted = await prettier.format(await readFile(file, 'utf8'), { - ...(await prettier.resolveConfig(file)), - filepath: file, - }); - const encoded = Buffer.from(formatted).toString('base64'); - for (let offset = 0; offset < encoded.length; offset += 1000) - console.error(`PRETTIER-DATA|src/agent/worldPacket.test.mjs|${offset}|${encoded.slice(offset, offset + 1000)}`); - } process.exitCode = 1; } else { console.log( diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index 48ce2b47d..342af34c3 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -71,94 +71,85 @@ test('World Packet preserves get_current_view_state parity', async () => { assert.equal(packet.feedProvenance.overall, 'nominal'); }); -test( - 'World Packet keeps stale/unavailable feed semantics distinct from empty', - () => { - const { viewer, dataManager, styleManager } = harness(); - dataManager.getAll = () => [ - { - id: 'traffic', - name: 'Traffic', - enabled: true, - stats: { - count: 0, - source: 'fixture', - error: 'fixture unavailable', - lastUpdate: null, - }, +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, }, - ]; + }, + ]; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - assert.equal(packet.layers[0].count, 0); - assert.notEqual(packet.layers[0].feedState, 'nominal'); - assert.notEqual(packet.feedProvenance.overall, 'nominal'); - assert.match(packet.feedProvenance.note, /fixture unavailable/i); - }, -); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); -test( - 'semantic fingerprint ignores narration prose but changes with observed state', - () => { - const { viewer, dataManager, styleManager } = harness(); - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - const original = semanticWorldPacketFingerprint(packet); +test('semantic fingerprint ignores narration prose but changes with observed state', () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); - const proseOnly = structuredClone(packet); - proseOnly.feedProvenance.note = 'different generated narration'; - assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); - const changed = structuredClone(packet); - changed.layers[0].count += 1; - assert.notEqual(semanticWorldPacketFingerprint(changed), original); - }, -); + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); +}); // The 2026-10 main voice contract omits disabled layers from the model payload. -test( - 'World Packet and voice preserve enabled-only payload and count disabled layers', - async () => { - const { viewer, dataManager, styleManager } = harness(); - const rows = dataManager.getAll(); - rows.push({ - id: 'earthquakes', - name: 'Earthquakes', - enabled: false, - stats: { count: 99, source: 'fixture', error: 'stale' }, - }); - let reads = 0; - dataManager.getAll = () => { - reads += 1; - return rows; - }; - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); - assert.equal(packet.disabledLayerCount, 1); - assert.deepEqual(packet.layers.map((layer) => layer.id), ['traffic']); - assert.equal(packet.feedProvenance.overall, 'nominal'); +test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { + const { viewer, dataManager, styleManager } = harness(); + const rows = dataManager.getAll(); + rows.push({ + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }); + let reads = 0; + dataManager.getAll = () => { + reads += 1; + return rows; + }; + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); + assert.equal(packet.disabledLayerCount, 1); + assert.deepEqual( + packet.layers.map((layer) => layer.id), + ['traffic'], + ); + assert.equal(packet.feedProvenance.overall, 'nominal'); - const runner = createGevActionRunner({ viewer, dataManager, styleManager }); - const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); - }, -); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + assert.deepEqual(voice, packet); +}); -test( - 'World Packet fingerprints distinguish enabled state but ignore narrative churn', - () => { - const { viewer, dataManager, styleManager } = harness(); - const first = getWorldPacket({ viewer, dataManager, styleManager }); - const prose = structuredClone(first); - prose.feedProvenance.note = 'new narration only'; - assert.equal( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(prose), - ); - const hidden = structuredClone(first); - hidden.disabledLayerCount += 1; - assert.notEqual( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(hidden), - ); - }, -); +test('World Packet fingerprints distinguish enabled state but ignore narrative churn', () => { + const { viewer, dataManager, styleManager } = harness(); + const first = getWorldPacket({ viewer, dataManager, styleManager }); + const prose = structuredClone(first); + prose.feedProvenance.note = 'new narration only'; + assert.equal( + semanticWorldPacketFingerprint(first), + semanticWorldPacketFingerprint(prose), + ); + const hidden = structuredClone(first); + hidden.disabledLayerCount += 1; + assert.notEqual( + semanticWorldPacketFingerprint(first), + semanticWorldPacketFingerprint(hidden), + ); +}); From 83fc895442fd99759b1e9d4849d374210e0e2c34 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:14:43 -0500 Subject: [PATCH 25/35] fix(agent): own portable i18n marker in World Packet boundary --- scripts/package-boundaries.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index aa45274d8..33c8ba2a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3934,7 +3934,8 @@ "src/data/feedState.js", "src/contextModePolicy.js", "src/loadingFeedback.js", - "src/data/installationFeedback.js" + "src/data/installationFeedback.js", + "src/i18n/marker.js" ], "external": ["cesium"] } From c09865e582e82696d53e462a4dc1a487778d3da4 Mon Sep 17 00:00:00 2001 From: Daniel Slay Date: Sat, 17 Oct 2026 13:22:36 -0400 Subject: [PATCH 26/35] feat(cctv): FL511 Florida camera pack (stills, opt-in) --- .env.example | 2 + DATA_SOURCES.md | 2 + README.md | 2 +- changelog.d/605-fl511-florida-cameras.md | 8 + docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 8 + server/providers/cctv/constants.js | 19 ++ server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 193 +++++++++++++ src/data/cctvFlorida.test.mjs | 336 +++++++++++++++++++++++ src/data/dataCredits.js | 7 + 11 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 changelog.d/605-fl511-florida-cameras.md create mode 100644 src/data/cctvFlorida.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..9edc74f0a 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,8 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, non-commercial use only; see DATA_SOURCES.md) +# CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first) # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0e04fb39b..6afc12f4a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -48,6 +48,7 @@ How to read this: | **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain | | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) | +| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual non-commercial use only ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) | | **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) | | **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) | | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) | @@ -160,6 +161,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. +- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged 2000 rows at a time to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, non-commercial use only; deployment beyond personal use needs FDOT's written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. diff --git a/README.md b/README.md index c49c53ece..b6600ea48 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual non-commercial use only). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md new file mode 100644 index 000000000..ecb65553a --- /dev/null +++ b/changelog.d/605-fl511-florida-cameras.md @@ -0,0 +1,8 @@ +- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: + the statewide ArcGIS catalog, read a page at a time under a byte cap with + redirects refused, and frames pinned to FL511's two image hosts over HTTPS. + FL511's terms limit its content to individual, non-commercial use, so the + pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + catalog's direction codes, and camera ids come from the image channel, since + the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` + sets the cap (Danielslay86, #605). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 105fdd37f..8581b18d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3371,7 +3371,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 75e58d2a6..91e5336cf 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -25,6 +25,7 @@ import { loadVegvesenSourcesFromOpenData, loadNycSourcesFromOpenData, loadTorontoSourcesFromOpenData, + loadFloridaSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -144,6 +145,13 @@ const LIVE_PACKS = [ enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), load: loadLithuaniaSources, }, + { + // FL511's terms limit its content to individual, non-commercial use, so + // the pack is off unless the operator opts in (see DATA_SOURCES.md). + name: 'florida', + enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'), + load: loadFloridaSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 20b963770..26d8b43b8 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -113,6 +113,25 @@ export const CALTRANS_ANCHORS = [ { lat: 32.7157, lon: -117.1611 }, // San Diego { lat: 38.5816, lon: -121.4944 }, // Sacramento ]; +export const FLORIDA_CCTV_URL = + 'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query'; +export const FLORIDA_IMAGE_HOSTS = [ + 'images-dis.divas.cloud', + 'snapshots.divas.cloud', +]; +export const DEFAULT_FLORIDA_MAX_SOURCES = 500; +/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */ +export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +export const FLORIDA_ANCHORS = [ + { lat: 25.7617, lon: -80.1918 }, // Miami + { lat: 28.5384, lon: -81.3789 }, // Orlando + { lat: 27.9506, lon: -82.4572 }, // Tampa + { lat: 30.3322, lon: -81.6557 }, // Jacksonville + { lat: 30.4383, lon: -84.2807 }, // Tallahassee + { lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale + { lat: 26.7153, lon: -80.0534 }, // West Palm Beach + { lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle) +]; /** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */ export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam'; export const TFL_IMAGE_ORIGIN = diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 2d4e16e11..37d846c1c 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) { ); } +/** Florida statewide, Key West to Georgia state line, with slack at each edge */ +export function isLikelyFloridaCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 24.3 && + lat <= 31.1 && + lon >= -87.7 && + lon <= -79.8 + ); +} + /** New York City's five boroughs, with slack for the harbor and city line. */ export function isLikelyNycCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c3d11e752..91a5aa35e 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -55,6 +55,11 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + FLORIDA_CCTV_URL, + FLORIDA_IMAGE_HOSTS, + DEFAULT_FLORIDA_MAX_SOURCES, + FLORIDA_ANCHORS, + FLORIDA_MAX_CATALOG_BYTES, CATALONIA_CAMERAS_URL, CATALONIA_MAX_CATALOG_BYTES, CATALONIA_IMAGE_HOSTS, @@ -113,6 +118,7 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, @@ -2784,3 +2790,190 @@ export async function loadTorontoSourcesFromOpenData() { return []; } } + +/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */ +const FLORIDA_DIRECTION_HEADINGS = Object.freeze({ + N: 0, + E: 90, + S: 180, + W: 270, +}); + +/** + * Stable camera id from a frame URL. FL511's ID column repeats across regions + * (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the + * key, as Calgary keys on its frame filename. Falls back to a path slug. + * + * @param {string} imageUrl - A host-pinned FL511 frame URL. + * @returns {?string} Provider-stable id, or null when underivable. + */ +export function floridaCameraId(imageUrl) { + let path; + try { + path = new URL(String(imageUrl ?? '')).pathname; + } catch { + return null; + } + const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path); + if (channel) return `fl-${channel[1]}`; + const slug = path + .replace(/^\/+|\.[a-z0-9]+$/gi, '') + .replace(/[^a-zA-Z0-9]+/g, '-') + .toLowerCase(); + return slug ? `fl-${slug}` : null; +} + +/** + * One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a + * one-letter code the free-text parser won't read, so a table maps it. + * + * @param {object} feature - ArcGIS feature from the FL511 camera layer. + * @returns {?object} + */ +export function floridaCameraToSource(feature) { + const a = feature?.attributes; + if (!a || typeof a !== 'object') return null; + const lat = toFiniteNumber(a.LATITUDE); + const lon = toFiniteNumber(a.LONGITUDE); + if (!isLikelyFloridaCoordinate(lat, lon)) return null; + + const imageUrl = String(a.IMAGE || ''); + let image; + try { + image = new URL(imageUrl); + } catch { + return null; + } + // Official image hosts only, HTTPS, no embedded credentials. + if ( + image.protocol !== 'https:' || + !FLORIDA_IMAGE_HOSTS.includes(image.host) || + image.username || + image.password + ) + return null; + + const cameraId = floridaCameraId(imageUrl); + if (!cameraId) return null; + + const heading = + FLORIDA_DIRECTION_HEADINGS[ + String(a.DIRECTION || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + + return { + id: cameraId, + name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`, + city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'), + cityId: 'florida', + provider: 'FL511', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: 5, // Coastal prior; the client's ground snap corrects. + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'fl511-open-data', + license: 'FL511 (FDOT), individual non-commercial use only', + }; +} + +/** + * Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged + * 2,000 rows at a time. Frames are stills on the Divas Cloud hosts. A redirect + * on any page drops the pack; any other failed page keeps the rows already read. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadFloridaSourcesFromOpenData() { + try { + const cameras = []; + const seen = new Set(); + const pageSize = 2000; + // Page ceiling, in case the service ignores resultOffset. + for (let offset = 0, page = 0; page < 10; offset += pageSize, page += 1) { + const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`; + let resp; + try { + resp = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + } catch (error) { + console.warn( + '[CCTV] Florida catalog page failed:', + error?.message || error, + ); + break; + } + // Release the body on every path that will not read it. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Florida catalog redirected; redirects are not followed', + ); + await discard(); + return []; + } + if (!resp.ok) { + console.warn('[CCTV] Florida camera download failed:', resp.status); + await discard(); + break; + } + let body; + try { + body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES); + } catch (error) { + console.warn( + '[CCTV] Florida catalog page unreadable:', + error?.message || error, + ); + break; + } + const feats = Array.isArray(body?.features) ? body.features : []; + for (const feature of feats) { + const camera = floridaCameraToSource(feature); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + // ArcGIS sets exceededTransferLimit while more rows remain. + if (feats.length < pageSize || body?.exceededTransferLimit !== true) + break; + } + + const maxRaw = Number( + process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(4200, Math.floor(maxRaw))) + : DEFAULT_FLORIDA_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS); + console.log( + `[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Florida camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvFlorida.test.mjs b/src/data/cctvFlorida.test.mjs new file mode 100644 index 000000000..17cd5284a --- /dev/null +++ b/src/data/cctvFlorida.test.mjs @@ -0,0 +1,336 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + floridaCameraId, + floridaCameraToSource, + loadFloridaSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; +import { + FLORIDA_CCTV_URL, + FLORIDA_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + fallbackHeadingFromId, + isLikelyFloridaCoordinate, +} from '../../server/providers/cctv/normalize.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +const IMAGE_HOST = 'https://images-dis.divas.cloud/DGI/'; + +/** A response with a live body, plus a flag that flips when it is cancelled. */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +/** One FL511 feature, shaped like the live FeatureServer payload. */ +const feature = (overrides = {}) => ({ + attributes: { + OBJECTID_1: 6, + ID: '1220', + DESCRIPT: 'I-75 @ MM 346.5 SB', + COUNTY: 'Marion', + HIGHWAY: 'I-75', + DIRECTION: 'S', + LATITUDE: 29.107363, + LONGITUDE: -82.188334, + IMAGE: `${IMAGE_HOST}chan-9431_h.jpg`, + ...overrides, + }, +}); + +/** One catalog page of `count` distinct cameras, channels from `start`. */ +const page = (start, count, extra = {}) => ({ + features: Array.from({ length: count }, (_, i) => + feature({ IMAGE: `${IMAGE_HOST}chan-${start + i}_h.jpg` }), + ), + ...extra, +}); + +const offsetOf = (url) => + Number(new URL(String(url)).searchParams.get('resultOffset')); + +/** Lift the pack cap for one test so every loaded camera comes back. */ +const uncapped = (t) => { + const previous = process.env.CCTV_FLORIDA_MAX_SOURCES; + process.env.CCTV_FLORIDA_MAX_SOURCES = '4200'; + t.after(() => { + if (previous === undefined) delete process.env.CCTV_FLORIDA_MAX_SOURCES; + else process.env.CCTV_FLORIDA_MAX_SOURCES = previous; + }); +}; + +test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => { + const source = floridaCameraToSource(feature()); + assert.equal(source.id, 'fl-9431'); + assert.equal(source.name, 'I-75 @ MM 346.5 SB'); + assert.equal(source.city, 'Marion County'); + assert.equal(source.cityId, 'florida'); + assert.equal(source.provider, 'FL511'); + assert.equal(source.lat, 29.107363); + assert.equal(source.lon, -82.188334); + assert.equal(source.headingDeg, 180); + assert.equal( + floridaCameraToSource(feature({ DIRECTION: 'E' })).headingDeg, + 90, + ); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.feedType, 'image'); + assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`); + assert.equal(source.url, source.snapshotUrl); + assert.equal(source.sourceKind, 'fl511-open-data'); + assert.match(source.license, /non-commercial/); +}); + +test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => { + const source = floridaCameraToSource( + feature({ DIRECTION: 'NOT DIRECTIONAL' }), + ); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.headingDeg, fallbackHeadingFromId('fl-9431')); +}); + +test('rows outside Florida, off-host frames and unusable rows are dropped', () => { + const dropped = [ + feature({ LATITUDE: 33.749, LONGITUDE: -84.388 }), // Atlanta + feature({ LATITUDE: 0, LONGITUDE: 0 }), + feature({ LATITUDE: -82.188334, LONGITUDE: 29.107363 }), // swapped + feature({ LATITUDE: '', LONGITUDE: '' }), + feature({ IMAGE: 'https://evil.example/DGI/chan-9431_h.jpg' }), + feature({ IMAGE: 'http://images-dis.divas.cloud/DGI/chan-9431_h.jpg' }), + feature({ + IMAGE: 'https://user:pass@images-dis.divas.cloud/DGI/chan-9431_h.jpg', + }), + feature({ IMAGE: 'not a url' }), + { attributes: null }, + null, + ]; + for (const row of dropped) assert.equal(floridaCameraToSource(row), null); +}); + +test('camera ids come from the image channel, not the repeating ID column', () => { + // FL511 reuses ID across regions: 1220 is both an I-75 and an I-95 camera. + const marion = floridaCameraToSource(feature()); + const broward = floridaCameraToSource( + feature({ + DESCRIPT: 'I-95 SB before E Cypress Rd', + COUNTY: 'Broward', + LATITUDE: 26.22006, + LONGITUDE: -80.13676, + IMAGE: `${IMAGE_HOST}chan-6351_h.jpg`, + }), + ); + assert.equal(marion.id, 'fl-9431'); + assert.equal(broward.id, 'fl-6351'); + // Any resolution suffix keys the same way. + assert.equal(floridaCameraId(`${IMAGE_HOST}chan-77_l.jpg`), 'fl-77'); + // A filename without a channel falls back to a stable path slug. + assert.equal( + floridaCameraId('https://snapshots.divas.cloud/US-1%20at%20SR-5.jpg'), + 'fl-us-1-20at-20sr-5', + ); + assert.equal(floridaCameraId('not a url'), null); +}); + +test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => { + const inside = [ + [24.5551, -81.78], // Key West + [30.4213, -87.2169], // Pensacola + [30.99, -84.0], // Georgia line + [25.7617, -80.1918], // Miami + ]; + const outside = [ + [33.749, -84.388], // Atlanta + [25.0443, -77.3504], // Nassau + [0, 0], + [-81.78, 24.5551], // swapped + [NaN, -81], + ]; + for (const [lat, lon] of inside) + assert.equal(isLikelyFloridaCoordinate(lat, lon), true); + for (const [lat, lon] of outside) + assert.equal(isLikelyFloridaCoordinate(lat, lon), false); +}); + +test('the loader pages until a short page and collapses repeated rows', async (t) => { + t.mock.method(console, 'log', () => {}); + uncapped(t); + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + assert.ok(String(url).startsWith(FLORIDA_CCTV_URL)); + calls.push([offsetOf(url), init.redirect]); + if (offsetOf(url) === 0) + return Response.json(page(0, 2000, { exceededTransferLimit: true })); + // Short last page: two new cameras and a repeat of one already read. + return Response.json(page(1999, 3)); + }); + const cameras = await loadFloridaSourcesFromOpenData(); + assert.deepEqual(calls, [ + [0, 'manual'], + [2000, 'manual'], + ]); + assert.equal(cameras.length, 2002); + assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002); +}); + +test('a redirect on any page drops the whole pack and releases it', async (t) => { + t.mock.method(console, 'warn', () => {}); + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras' }, + }); + t.mock.method(globalThis, 'fetch', async (url) => + offsetOf(url) === 0 + ? Response.json(page(0, 2000, { exceededTransferLimit: true })) + : redirected.response, + ); + assert.deepEqual(await loadFloridaSourcesFromOpenData(), []); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); +}); + +test('a failed later page keeps the pages already read', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + uncapped(t); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async (url) => + offsetOf(url) === 0 + ? Response.json(page(0, 2000, { exceededTransferLimit: true })) + : failed.response, + ); + assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + // A network error on a later page keeps them too. + t.mock.restoreAll(); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + if (offsetOf(url) === 0) + return Response.json(page(0, 2000, { exceededTransferLimit: true })); + throw new Error('network down'); + }); + assert.equal((await loadFloridaSourcesFromOpenData()).length, 2000); +}); + +test('an oversized catalog page is refused', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method( + globalThis, + 'fetch', + async () => + new Response(JSON.stringify(page(0, 1)), { + headers: { + 'Content-Type': 'application/json', + 'content-length': String(FLORIDA_MAX_CATALOG_BYTES + 1), + }, + }), + ); + assert.deepEqual(await loadFloridaSourcesFromOpenData(), []); + + // So is a body that only turns out too long while streaming. + t.mock.restoreAll(); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(FLORIDA_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + assert.deepEqual(await loadFloridaSourcesFromOpenData(), []); +}); + +/** + * Serve a two-camera FL511 page to the Florida endpoint and an empty payload + * to every other pack, so one catalog refresh exercises the registration + * without reaching the network. Returns the URLs that were requested. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href.startsWith(FLORIDA_CCTV_URL)) return Response.json(page(0, 2)); + return Response.json([]); + }); + // No curated catalogs or ground-height sidecar, so only live lanes are in play. + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +/** Put process.env back exactly as it was. */ +const restoreEnv = (saved) => { + for (const key of Object.keys(process.env)) { + if (key in saved) continue; + delete process.env[key]; + } + Object.assign(process.env, saved); +}; + +test('with CCTV_FLORIDA_ENABLED=1 the Florida lane is wired into the catalog', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_FLORIDA_ENABLED = '1'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)), + 'the catalog refresh invokes the Florida loader', + ); + assert.deepEqual( + sources + .filter((s) => s.cityId === 'florida') + .map((s) => s.id) + .sort(), + ['fl-0', 'fl-1'], + 'Florida cameras reach the served catalog through the registered lane', + ); + } finally { + restoreEnv(saved); + } +}); + +test('the Florida lane stays off unless CCTV_FLORIDA_ENABLED=1', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_FLORIDA_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.some((href) => href.startsWith(FLORIDA_CCTV_URL)), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'florida'), + [], + ); + } finally { + restoreEnv(saved); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index c13c7dd94..deba638e4 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -263,6 +263,13 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'florida-cctv', + html: + 'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' + + 'Individual, non-commercial use only under the ' + + 'FL511 terms', + }, { key: 'catalonia-cctv', html: From 1c4c903ac61ba014259d4dbd74bee9910acc7ad7 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:12:46 -0500 Subject: [PATCH 27/35] fix(agent): align World Packet with reviewer feedback and voice parity Reuse one context/contacts/tracking owner in voice and World Packet. Compare voice outputs after stripping presentation fields; remove the unconsumed fingerprint API pending an actual caller. Document the exported packet boundary and reject null options safely. Review guidance: asturksever on origin #880; Windows parity diagnosis by quanticsoul4772. Existing source ownership retained. --- changelog.d/880-world-packet-boundary.md | 2 +- docs/CURRENT-STATE.md | 5 + src/agent/worldPacket.js | 48 ++-------- src/agent/worldPacket.test.mjs | 48 +++------- src/voice/gevActions.js | 112 ++--------------------- 5 files changed, 34 insertions(+), 181 deletions(-) diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md index f1bedb614..7ee574e35 100644 --- a/changelog.d/880-world-packet-boundary.md +++ b/changelog.d/880-world-packet-boundary.md @@ -2,5 +2,5 @@ `get_current_view_state` owner without restoring disabled layers to the model context. Keep the enabled-only payload, disabled-layer count, source provenance, action result and view-state contract; read the layer roster once - per packet. The semantic fingerprint is for change detection, not authority. + per packet; voice retains the speech/card envelope at its own boundary. Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..7a00217b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -474,6 +474,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft, get_entity_context, annotate_map and analyst_query. Analyst speech and the card share one deterministic count/scope headline; lower bounds use "At least" and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only. +The model-free `gods-eye-view/agent/world-packet` export exposes +`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to +non-voice consumers. It reads the same enabled-only state and feed +provenance before voice adds speech/card fields, with no new network requests +or mutations. The voice card nested in the mic control (`voiceCard.js`) shows captions, plan steps and the result. `resultDisplay.js` is the one adapter from a result to the card: builder results show as built, and analyst results show a title diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index 7315656b6..0987a7fce 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -6,7 +6,7 @@ import { } from '../data/layerSnapshot.js'; import { contextModeWord } from '../contextModePolicy.js'; -const TRACKABLE_FAMILIES = Object.freeze([ +export const TRACKABLE_FAMILIES = Object.freeze([ { layerId: 'flights', kind: 'aircraft' }, { layerId: 'military', kind: 'aircraft' }, { layerId: 'ais-live-vessels', kind: 'vessel' }, @@ -24,37 +24,6 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); - -function semanticPacketValue(value) { - if (Array.isArray(value)) return value.map(semanticPacketValue); - if (!value || typeof value !== 'object') return value; - const out = {}; - for (const key of Object.keys(value).sort()) { - if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; - out[key] = semanticPacketValue(value[key]); - } - return out; -} - -/** - * Stable semantic fingerprint for change detection, not authentication. - * - * Presentation-only narration/age labels are excluded. Feed state, source, - * lastUpdate, camera, context, tracked entities, and other semantic state stay - * inside the digest. - */ -export function semanticWorldPacketFingerprint(packet) { - const canonical = JSON.stringify(semanticPacketValue(packet)); - let hash = 0xcbf29ce484222325n; - const prime = 0x100000001b3n; - for (let i = 0; i < canonical.length; i += 1) { - hash ^= BigInt(canonical.charCodeAt(i)); - hash = BigInt.asUintN(64, hash * prime); - } - return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; -} - /** * Translate context-mode payloads into the public tool vocabulary while * retaining the internal id beside each translated field. @@ -124,13 +93,14 @@ export function activeContactsWindow(dataManager) { * This performs no network requests and intentionally mirrors the historical * get_current_view_state payload so voice can delegate without behavior drift. */ -export function getWorldPacket({ - viewer, - styleManager, - dataManager, - sceneDirector = null, - now = Date.now(), -} = {}) { +export function getWorldPacket(options = {}) { + const { + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), + } = options ?? {}; if (!viewer?.camera?.positionWC) throw new TypeError('World Packet requires a viewer camera'); if (!dataManager || typeof dataManager.getAll !== 'function') diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index 342af34c3..c4d0200ba 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -2,12 +2,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { - getWorldPacket, - semanticWorldPacketFingerprint, -} from './worldPacket.js'; +import { getWorldPacket } from './worldPacket.js'; import { createGevActionRunner } from '../voice/gevActions.js'; +function withoutVoicePresentation(result) { + const packet = { ...result }; + delete packet.say; + delete packet.display; + delete packet.schedule; + return packet; +} + function harness() { const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); const viewer = { @@ -64,7 +69,7 @@ test('World Packet preserves get_current_view_state parity', async () => { const runner = createGevActionRunner({ viewer, dataManager, styleManager }); const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); + assert.deepEqual(withoutVoicePresentation(voice), packet); assert.equal(packet.context.mode, 'contacts'); assert.equal(packet.context.modeInternal, 'flights'); assert.equal(packet.layers[0].feedState, 'nominal'); @@ -94,20 +99,6 @@ test('World Packet keeps stale/unavailable feed semantics distinct from empty', assert.match(packet.feedProvenance.note, /fixture unavailable/i); }); -test('semantic fingerprint ignores narration prose but changes with observed state', () => { - const { viewer, dataManager, styleManager } = harness(); - const packet = getWorldPacket({ viewer, dataManager, styleManager }); - const original = semanticWorldPacketFingerprint(packet); - - const proseOnly = structuredClone(packet); - proseOnly.feedProvenance.note = 'different generated narration'; - assert.equal(semanticWorldPacketFingerprint(proseOnly), original); - - const changed = structuredClone(packet); - changed.layers[0].count += 1; - assert.notEqual(semanticWorldPacketFingerprint(changed), original); -}); - // The 2026-10 main voice contract omits disabled layers from the model payload. test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { const { viewer, dataManager, styleManager } = harness(); @@ -134,22 +125,9 @@ test('World Packet and voice preserve enabled-only payload and count disabled la const runner = createGevActionRunner({ viewer, dataManager, styleManager }); const voice = await runner('get_current_view_state'); - assert.deepEqual(voice, packet); + assert.deepEqual(withoutVoicePresentation(voice), packet); }); -test('World Packet fingerprints distinguish enabled state but ignore narrative churn', () => { - const { viewer, dataManager, styleManager } = harness(); - const first = getWorldPacket({ viewer, dataManager, styleManager }); - const prose = structuredClone(first); - prose.feedProvenance.note = 'new narration only'; - assert.equal( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(prose), - ); - const hidden = structuredClone(first); - hidden.disabledLayerCount += 1; - assert.notEqual( - semanticWorldPacketFingerprint(first), - semanticWorldPacketFingerprint(hidden), - ); +test('World Packet rejects null options with the camera requirement', () => { + assert.throws(() => getWorldPacket(null), /requires a viewer camera/); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 2f7ea7824..c3833e571 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,6 +1,5 @@ import { layerSnapshot, - layerSnapshots, feedProvenanceEnvelope, } from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; @@ -26,7 +25,6 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; -import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -47,7 +45,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; -import { getWorldPacket } from '../agent/worldPacket.js'; +import { + getWorldPacket, + withContextModeVocabulary, + activeContactsWindow, + TRACKABLE_FAMILIES, +} from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -119,64 +122,6 @@ const CONTEXT_MODE_ALIASES = new Map([ ['space-missions', 'space-missions'], ['missions', 'space-missions'], ]); -/** - * Every model-readable field that carries a context-mode id, and what an - * absent value means for each. - * - * `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode` - * are absent when there is no such mode at all — calling those 'off' would - * assert a state that does not exist. - */ -const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ - { field: 'mode', emptyAs: 'off' }, - { field: 'entering', emptyAs: null }, - { field: 'priorMode', emptyAs: null }, -]); - -/** Nested results that are themselves context-mode payloads the model reads. */ -const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ - 'context', - 'contextRollback', -]); - -/** - * Report a context-mode payload in the tools' own vocabulary. - * - * `set_context_mode` accepts 'contacts' while the mode's internal id is - * 'flights'. Reporting the internal id back made the model read - * `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts - * window counts sitting in the very same payload (owner field session - * 2026-08-21). Secondary fields and nested transition/rollback results are - * translated too — one leaked internal id is enough to recreate the confusion, - * and a rollback result is exactly what the model reads when something went - * wrong. Each internal id is kept alongside as `Internal` for anything - * reasoning about layers. - * @param {object|null|undefined} state Any payload carrying context-mode fields. - * @returns {object|null|undefined} The same payload, modes translated. - */ -function withContextModeVocabulary(state) { - if (!state || typeof state !== 'object') return state; - let out = state; - const mutable = () => { - if (out === state) out = { ...state }; - return out; - }; - for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { - if (!(field in state)) continue; - const internal = state[field] ?? null; - const target = mutable(); - target[field] = contextModeWord(internal, { emptyAs }); - target[`${field}Internal`] = internal; - } - for (const field of NESTED_CONTEXT_RESULT_FIELDS) { - const nested = state[field]; - if (!nested || typeof nested !== 'object') continue; - const translated = withContextModeVocabulary(nested); - if (translated !== nested) mutable()[field] = translated; - } - return out; -} - const COCKPIT_ACTION_ALIASES = new Map([ ['next', 'next'], ['previous', 'previous'], @@ -246,14 +191,6 @@ const STACK_ALIASES = new Map([ ['road map', 'osm'], ]); -/** Search order for track_entity across entity layer families. */ -const TRACKABLE_FAMILIES = [ - { layerId: 'flights', kind: 'aircraft' }, - { layerId: 'military', kind: 'aircraft' }, - { layerId: 'ais-live-vessels', kind: 'vessel' }, - { layerId: 'satellites', kind: 'satellite' }, -]; - const FRAME_TARGETS = new Map([ ['flights', 'flights'], ['planes', 'flights'], @@ -2553,26 +2490,6 @@ function runManagedVoiceNavigation( }; } -/** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -4599,23 +4516,6 @@ function clampNumber(value, min, max, fallback) { * ("which of those is closest?") scoped to the session. */ -/** - * The Contacts panel's own counts, or null when Contacts has no subject. - * Read through the awareness snapshot the panel renders, so the two cannot - * diverge no matter which surface asks. - * @returns {object|null} Panel-equivalent window counts. - */ -function activeContactsWindow(dataManager) { - try { - const layer = dataManager?.layers?.get('military-awareness')?.module; - return ( - layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null - ); - } catch { - return null; - } -} - /** * Most records one layer hands a single query. Above every loaded feed seen * so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so From daff74fe04b7bd76ad7a0f42dd8ea38b3b4418d9 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:17:43 -0500 Subject: [PATCH 28/35] test(navigation): read trackable nouns from canonical packet owner World Packet #880 removes the duplicate tracking table from gevActions; the Cockpit refusal source-scan test should inspect the actual exported source of truth instead of assuming the previous file position. Fix failing Node 24 exact-head CI without duplicating runtime definitions. --- src/navigationPolicyFrame.test.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs index a861870cf..5110232f1 100644 --- a/src/navigationPolicyFrame.test.mjs +++ b/src/navigationPolicyFrame.test.mjs @@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { TRACKABLE_FAMILIES } from './agent/worldPacket.js'; import { setLocale } from './i18n/index.js'; import { COCKPIT_REFUSAL_NOUNS, @@ -68,10 +69,8 @@ const NOUN_LITERAL = /\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g; function trackableKinds() { - const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8'); - const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/); - assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js'); - return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]); + // This is shared with voice; test the exported owner, not its file position. + return TRACKABLE_FAMILIES.map(({ kind }) => kind); } // Non-literal arguments: what each one can be. A wrapper's own `noun` (or all From aec4f67ca97ae6667a1055f7f5521fa15692590b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:45:25 -0700 Subject: [PATCH 29/35] feat(tools): count aircraft in an area by airline OpenSky, the default civil feed, reports no operator, so group_by: operator said "no operator reported". aircraft_in_area now also takes group_by: 'airline', read from the ICAO designator in each callsign: three letters followed by a flight number that starts with a digit (SWA1234, UAL12A). Registrations used as callsigns (N123AB) count as unknown. Rows are unchanged; the designator is derived for the breakdown only. Live, Texas: "785 aircraft ...; most by airline: AAL (89), SWA (65), UAL (52); 283 unknown." Co-Authored-By: Claude Opus 5.5 --- changelog.d/1056-area-tool-breakdowns.md | 10 +++--- docs/CURRENT-STATE.md | 9 +++--- src/tools/queries/aviation.js | 25 +++++++++++++-- src/tools/queries/aviation.test.mjs | 41 ++++++++++++++++++++++++ src/tools/results.js | 8 +++-- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 75f713907..67ec8d766 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -1,6 +1,8 @@ - `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which countries, aircraft types or ship types dominate an - area, or the average altitude or speed, over every match instead of - estimating from the returned rows. Operator and type are counted only where - the feed reports them; AIS ship type 0 ("not available") counts as unknown + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them; AIS ship type 0 ("not available") counts as unknown (quanticsoul4772, #1056). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9bc129b8f..9f86c1219 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -98,10 +98,11 @@ combined situation brief, military awareness around a point, the app's heads-up opens the app over an area. Tools reuse the layers' portable source factories, take a shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also -count every match by a field (`group_by`: operator, country or type for -aircraft; type or destination for ships; the ten largest groups, then other and -unknown counts; the summary says when a feed reports none of the field) and -summarize airborne altitude and speed (`stats`: count, min, median, mean, max). +count every match by a field (`group_by`: airline, read from the ICAO +designator in the callsign, or operator, country or type for aircraft; type or +destination for ships; the ten largest groups, then other and unknown counts; +the summary says when a feed reports none of the field) and summarize airborne +altitude and speed (`stats`: count, min, median, mean, max). Both cover all matches, not just the rows returned; voice leaves these two tools out, so they reach MCP clients only. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index d3d9bbeaa..6a4cbd54e 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -36,11 +36,29 @@ const MILITARY_SCHEMA = Object.freeze({ }); const MAX_TRACK_POINTS = 200; const GROUP_LABELS = Object.freeze({ + airline: 'airline', operator: 'operator', origin_country: 'country', type_code: 'type', }); +/** + * The ICAO airline designator a callsign flies under: three letters, then a + * flight number that starts with a digit (SWA1234, UAL12A, BAW1). Registrations + * used as callsigns (N123AB, GABCD) have no designator. + */ +export function airlineDesignator(callsign) { + const match = /^([A-Z]{3})\d[0-9A-Z]{0,4}$/.exec( + String(callsign ?? '') + .trim() + .toUpperCase(), + ); + return match ? match[1] : null; +} + +const groupValue = (field) => + field === 'airline' ? (row) => airlineDesignator(row.callsign) : undefined; + const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; @@ -91,7 +109,8 @@ export const aircraftInArea = defineTool({ description: 'Aircraft currently reported inside an area, nearest the center first. ' + 'Optionally military aircraft only, airborne only, or within an altitude band. ' + - 'group_by counts every matched aircraft by operator, country or type, and ' + + 'group_by counts every matched aircraft by airline (the ICAO designator ' + + 'in its callsign), operator, country or type, and ' + 'stats summarizes airborne altitude and speed; both cover all matches, not ' + 'just the rows returned.', inputSchema: { @@ -151,7 +170,9 @@ export const aircraftInArea = defineTool({ ...(regional ? [`regional feed: ${snapshot.coverage}`] : []), ...(snapshot.freshness === 'stale' ? ['data may be stale'] : []), ]; - const groups = args.group_by ? breakdown(rows, args.group_by) : null; + const groups = args.group_by + ? breakdown(rows, args.group_by, groupValue(args.group_by)) + : null; return { summary: `${rows.length} ${kind} in ${area.label}` + diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 14b2e2d38..721385ef7 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; const record = (id, latitude, longitude, extra = {}) => ({ @@ -504,3 +505,43 @@ test('altitude bands require a reported altitude from normalized OpenSky records }); assert.equal(minimum.data.total, 3); }); + +test('an airline designator is read from an ICAO flight callsign only', () => { + for (const [callsign, designator] of [ + ['SWA1234', 'SWA'], + ['ual12a', 'UAL'], + [' BAW1 ', 'BAW'], + ['RCH123', 'RCH'], + ['N123AB', null], + ['GABCD', null], + ['SWA', null], + ['SWAX123', null], + ['', null], + [null, null], + ]) + assert.equal(airlineDesignator(callsign), designator, String(callsign)); +}); + +test('aircraft in an area can be counted by airline from their callsigns', async () => { + const fleet = [ + record('a1', 37.6, -122.3, { callsign: 'SWA100' }), + record('a2', 37.61, -122.3, { callsign: 'SWA2' }), + record('a3', 37.62, -122.3, { callsign: 'UAL9' }), + record('a4', 37.63, -122.3, { callsign: 'N123AB' }), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(fleet) }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + group_by: 'airline', + }); + assert.equal( + result.summary, + '4 aircraft in 60 km around 37.600, -122.300; most by airline: SWA (2), UAL (1); 1 unknown.', + ); + assert.equal(result.data.breakdown.field, 'airline'); + // Rows are unchanged: the designator is derived, not added to each row. + assert.equal('airline' in result.data.rows[0], false); +}); diff --git a/src/tools/results.js b/src/tools/results.js index 952b2723e..247383757 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -28,13 +28,15 @@ export const MAX_GROUPS = 10; /** * Count rows by one text field over every row, not just those returned. * Values group case-insensitively and keep their first spelling; rows - * without a value are counted as unknown. + * without a value are counted as unknown. `valueOf` derives the value when + * it is not a row field as it stands. */ -export function breakdown(rows, field) { +export function breakdown(rows, field, valueOf = (row) => row[field]) { const groups = new Map(); let unknown = 0; for (const row of rows) { - const value = typeof row[field] === 'string' ? row[field].trim() : ''; + const raw = valueOf(row); + const value = typeof raw === 'string' ? raw.trim() : ''; if (!value) { unknown += 1; continue; From 7bf268ec4287af8e097fec00fc14cfb406ece5e1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:37:07 -0700 Subject: [PATCH 30/35] fix(tools): read an airline only from a callsign of seven characters or fewer ICAO flight-plan Item 7 caps aircraft identification at seven characters, so the flight number after the designator is one to four characters, not five. Philippine RP-C1234 and Cuban CU-T1240 registrations read like a designator plus a flight number once the hyphen is dropped; they now count as unknown. Cubana's CUB123 still counts as CUB. Co-Authored-By: Claude Opus 5.5 --- src/tools/queries/aviation.js | 16 +++++++++------- src/tools/queries/aviation.test.mjs | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 6a4cbd54e..fa2ff1172 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -44,15 +44,17 @@ const GROUP_LABELS = Object.freeze({ /** * The ICAO airline designator a callsign flies under: three letters, then a - * flight number that starts with a digit (SWA1234, UAL12A, BAW1). Registrations - * used as callsigns (N123AB, GABCD) have no designator. + * flight number that starts with a digit, seven characters at most (SWA1234, + * UAL12A, BAW1). Registrations used as callsigns (N123AB, GABCD) have no + * designator; Philippine RP-C1234 and Cuban CU-T1240, which read like one + * without their hyphen, are excluded too. */ export function airlineDesignator(callsign) { - const match = /^([A-Z]{3})\d[0-9A-Z]{0,4}$/.exec( - String(callsign ?? '') - .trim() - .toUpperCase(), - ); + const text = String(callsign ?? '') + .trim() + .toUpperCase(); + if (/^(?:RPC|CUT)\d{4}$/.test(text)) return null; + const match = /^([A-Z]{3})\d[0-9A-Z]{0,3}$/.exec(text); return match ? match[1] : null; } diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 721385ef7..32c1e02cd 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -516,6 +516,10 @@ test('an airline designator is read from an ICAO flight callsign only', () => { ['GABCD', null], ['SWA', null], ['SWAX123', null], + ['SWA12345', null], + ['RPC1234', null], + ['CUT1240', null], + ['CUB123', 'CUB'], ['', null], [null, null], ]) From d39256e70013d911b29b36f2904f53a1583d8755 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:33:40 -0700 Subject: [PATCH 31/35] fix(tools): ship types read as their family; letterless destinations are unknown AIS sends ship types as ITU-R M.1371 codes, so vessels_in_area grouped "70", "74" and "79" as three types, and type: "tanker" matched nothing on feeds that send codes. Codes now read as the family the vessel cards already show (CARGO, TANKER, TUG), for rows, group_by and the type filter. The code table moves to src/data/vesselTypes.js, which has no i18n import, so the server-side tools can use it; vesselLabels re-exports it. A destination with no letters ("0", "---") now counts as unknown. Live, Singapore: "most by type: CARGO (3), TANKER (3), PASSENGER (1)"; "how many tankers near Singapore" found 3. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1056-area-tool-breakdowns.md | 3 +- scripts/package-boundaries.json | 4 ++ src/data/vesselLabels.js | 43 ++---------------- src/data/vesselTypes.js | 45 +++++++++++++++++++ src/tools/queries/maritime.js | 22 ++++++--- src/tools/queries/maritime.test.mjs | 57 ++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/data/vesselTypes.js diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 67ec8d766..5425b3651 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -4,5 +4,6 @@ of estimating from the returned rows. Airlines come from the ICAO designator in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds that report no operator; operator and type are counted only where the feed - reports them; AIS ship type 0 ("not available") counts as unknown + reports them. Ship types count by family, so AIS codes 70 to 79 are all + cargo, and type 0 ("not available") counts as unknown (quanticsoul4772, #1056). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 43bc3d4de..07f2545a3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1040,6 +1040,7 @@ "src/layers/vessels/tracking.js", "src/data/iconOrientation.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", @@ -1841,6 +1842,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/firstRunExperience.js", "src/hud.js", "src/hudLocality.js", @@ -2629,6 +2631,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", "src/layers/alpr/index.js", @@ -3782,6 +3785,7 @@ "src/tools/queries/maritime.js", "src/tools/queries/share.js", "src/data/idSwitches.js", + "src/data/vesselTypes.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 311a238c6..fbf209040 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -5,6 +5,9 @@ */ import { t, msg } from '../i18n/index.js'; +import { normalizeVesselType } from './vesselTypes.js'; + +export { normalizeVesselType }; export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; /** Existing selector grid size; one ambient winner is retained per cell. */ @@ -43,31 +46,6 @@ const TYPE_STYLES = [ ]; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; -const NUMERIC_TYPE_SPECIALS = { - 30: 'FISHING', - 31: 'TOWING', - 32: 'TOWING', - 33: 'DREDGER', - 34: 'DIVE OPS', - 35: 'MILITARY', - 36: 'SAILING', - 37: 'PLEASURE', - 50: 'PILOT', - 51: 'SAR', - 52: 'TUG', - 53: 'PORT TENDER', - 54: 'ANTI-POLLUTION', - 55: 'LAW ENFORCE', - 58: 'MEDICAL', -}; -const NUMERIC_TYPE_FAMILIES = { - 4: 'HIGH-SPEED', - 6: 'PASSENGER', - 7: 'CARGO', - 8: 'TANKER', - 9: 'OTHER', -}; - // Card text for the type tokens above. The tokens stay English: TYPE_STYLES // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ @@ -104,21 +82,6 @@ export function displayVesselType(type) { return key ? t(key) : normalized; } -/** - * Resolve an AIS type to display text: bare numeric ship-type codes map to - * family names ("71" → "CARGO"); text types pass through unchanged. - * @param {string} type Raw AIS type. - * @returns {string} - */ -export function normalizeVesselType(type) { - const text = String(type || '').trim(); - if (!text || !/^\d{1,2}$/.test(text)) return text; - const code = Number(text); - if (code <= 0) return ''; - if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; - return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; -} - /** AIS ship type → CSS hex hue for the billboard chevron. */ export function vesselTypeCss(type) { return styleForType(type).css; diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js new file mode 100644 index 000000000..12886795c --- /dev/null +++ b/src/data/vesselTypes.js @@ -0,0 +1,45 @@ +/** + * @module vesselTypes + * @description AIS ship-type codes (ITU-R M.1371) to family names, with no + * browser or i18n dependencies so the server-side query tools can use it. + */ + +const NUMERIC_TYPE_SPECIALS = { + 30: 'FISHING', + 31: 'TOWING', + 32: 'TOWING', + 33: 'DREDGER', + 34: 'DIVE OPS', + 35: 'MILITARY', + 36: 'SAILING', + 37: 'PLEASURE', + 50: 'PILOT', + 51: 'SAR', + 52: 'TUG', + 53: 'PORT TENDER', + 54: 'ANTI-POLLUTION', + 55: 'LAW ENFORCE', + 58: 'MEDICAL', +}; +const NUMERIC_TYPE_FAMILIES = { + 4: 'HIGH-SPEED', + 6: 'PASSENGER', + 7: 'CARGO', + 8: 'TANKER', + 9: 'OTHER', +}; + +/** + * Resolve an AIS type to display text: bare numeric ship-type codes map to + * family names ("71" → "CARGO"); text types pass through unchanged. + * @param {string} type Raw AIS type. + * @returns {string} + */ +export function normalizeVesselType(type) { + const text = String(type || '').trim(); + if (!text || !/^\d{1,2}$/.test(text)) return text; + const code = Number(text); + if (code <= 0) return ''; + if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; + return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; +} diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index b5bdc4afe..5798a0936 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ type: 'string', @@ -32,11 +33,17 @@ const ALL_VESSELS = 1_000_000; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null; const text = (value) => (typeof value === 'string' && value ? value : null); -// AIS ship type 0 is "not available" (ITU-R M.1371); the source keeps it as -// '0', so it is reported as unknown rather than counted as a type. -const vesselType = (value) => { - const type = text(value); - return type === '0' ? null : type; +// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so +// a code reads as its family ("CARGO"), which is what group_by counts and the +// type filter matches; text types pass through. Type 0 is "not available" and +// reads as unknown. +const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null; + +// Crews type the destination by hand; one with no letters ("0", "---") names +// no port and reads as unknown. +const vesselDestination = (value) => { + const destination = text(value); + return destination && /\p{L}/u.test(destination) ? destination : null; }; function vesselRow(record, from) { @@ -46,7 +53,7 @@ function vesselRow(record, from) { name: text(record.name), imo: text(record.imo), type: vesselType(record.type), - destination: text(record.destination), + destination: vesselDestination(record.destination), lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), @@ -134,7 +141,8 @@ export const vesselsInArea = defineTool({ .filter( (record) => areaContains(area, { lat: record.latitude, lon: record.longitude }) && - (!wanted || String(record.type).toLowerCase().includes(wanted)), + (!wanted || + (vesselType(record.type) ?? '').toLowerCase().includes(wanted)), ) .map((record) => vesselRow(record, center)) .sort((a, b) => a.distance_km - b.distance_km); diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index f1d09bccb..dfa1fd761 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -263,6 +263,63 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async () ); }); +test('numeric AIS ship-type codes group and filter by their family', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { type: '70' }), + vessel('366000002', 37.79, -122.41, { type: '74' }), + vessel('366000003', 37.77, -122.39, { type: '79' }), + vessel('366000004', 37.76, -122.38, { type: '80' }), + vessel('366000005', 37.75, -122.37, { type: '52' }), + ], + }), + }, + }); + const grouped = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.deepEqual(grouped.data.breakdown.groups, [ + { value: 'CARGO', count: 3 }, + { value: 'TANKER', count: 1 }, + { value: 'TUG', count: 1 }, + ]); + const tankers = await catalog.call('vessels_in_area', { + area: bay, + type: 'tanker', + }); + assert.deepEqual( + tankers.data.rows.map((row) => [row.mmsi, row.type]), + [['366000004', 'TANKER']], + ); +}); + +test('a destination with no letters counts as unknown', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { destination: '0' }), + vessel('366000002', 37.79, -122.41, { destination: '---' }), + vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }), + ], + }), + }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'destination', + }); + assert.deepEqual(result.data.breakdown.groups, [ + { value: 'NLRTM', count: 1 }, + ]); + assert.equal(result.data.breakdown.unknown_count, 2); +}); + test('vessels in an area can be counted by destination with a speed summary', async () => { const catalog = composeCatalog({ tools: coreTools, From 4c383214f7ffd678da0749084d0f32af4dc30a94 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:29:11 +0100 Subject: [PATCH 32/35] fix(tools): average aircraft speed over airborne aircraft only aircraft_in_area stats summarized altitude over airborne aircraft but speed over every match, so parked aircraft pulled the mean speed toward zero. Both figures now use the same airborne subset, and the on-ground fixture is stationary so the test can tell the difference. Reserved AIS ship-type codes 100 to 255 now read as unknown, as type 0 does, instead of showing up in the breakdown as their own type. Adds tests for an area where every aircraft is on the ground and for find_vessel type and destination normalization, and notes in the changelog fragment that every vessel answer is now normalized. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1056-area-tool-breakdowns.md | 7 ++- src/data/vesselLabels.test.mjs | 2 + src/data/vesselTypes.js | 8 ++-- src/tools/queries/aviation.js | 9 ++-- src/tools/queries/aviation.test.mjs | 51 ++++++++++++++++++---- src/tools/queries/maritime.test.mjs | 54 ++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 19 deletions(-) diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 5425b3651..3ca857a45 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -5,5 +5,8 @@ in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds that report no operator; operator and type are counted only where the feed reports them. Ship types count by family, so AIS codes 70 to 79 are all - cargo, and type 0 ("not available") counts as unknown - (quanticsoul4772, #1056). + cargo; type 0 ("not available") and the reserved codes 100 to 255 count as + unknown. Altitude and speed stats cover airborne aircraft only, so parked + aircraft do not pull the means down. Every vessel answer, `find_vessel` + included, now reads AIS ship-type codes as their family and a destination + with no letters as unknown (quanticsoul4772, #1056). diff --git a/src/data/vesselLabels.test.mjs b/src/data/vesselLabels.test.mjs index f69fffcc7..1573daa75 100644 --- a/src/data/vesselLabels.test.mjs +++ b/src/data/vesselLabels.test.mjs @@ -29,6 +29,8 @@ test('normalizeVesselType preserves text and degrades unknown codes', () => { assert.equal(normalizeVesselType('Crude Oil Tanker'), 'Crude Oil Tanker'); assert.equal(normalizeVesselType('0'), ''); assert.equal(normalizeVesselType('25'), 'OTHER'); + assert.equal(normalizeVesselType('100'), ''); + assert.equal(normalizeVesselType('255'), ''); assert.equal(normalizeVesselType(undefined), ''); }); diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index 12886795c..550f4596b 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -31,15 +31,17 @@ const NUMERIC_TYPE_FAMILIES = { /** * Resolve an AIS type to display text: bare numeric ship-type codes map to - * family names ("71" → "CARGO"); text types pass through unchanged. + * family names ("71" → "CARGO"); text types pass through unchanged. Code 0 + * ("not available") and the reserved codes 100 to 255 name no type and + * resolve to "". * @param {string} type Raw AIS type. * @returns {string} */ export function normalizeVesselType(type) { const text = String(type || '').trim(); - if (!text || !/^\d{1,2}$/.test(text)) return text; + if (!text || !/^\d+$/.test(text)) return text; const code = Number(text); - if (code <= 0) return ''; + if (code <= 0 || code >= 100) return ''; if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; } diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index fa2ff1172..a5f4babf0 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -175,6 +175,7 @@ export const aircraftInArea = defineTool({ const groups = args.group_by ? breakdown(rows, args.group_by, groupValue(args.group_by)) : null; + const airborne = rows.filter((row) => !row.on_ground); return { summary: `${rows.length} ${kind} in ${area.label}` + @@ -189,12 +190,10 @@ export const aircraftInArea = defineTool({ ...(groups ? { breakdown: groups } : {}), ...(args.stats ? { + // Parked aircraft would pull both figures toward zero. stats: { - altitude_m: numericSummary( - rows.filter((row) => !row.on_ground), - 'altitude_m', - ), - speed_mps: numericSummary(rows, 'speed_mps'), + altitude_m: numericSummary(airborne, 'altitude_m'), + speed_mps: numericSummary(airborne, 'speed_mps'), }, } : {}), diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 32c1e02cd..1af1352c0 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -395,7 +395,11 @@ test('aircraft in an area can be counted by operator over every match', async () record('a1', 37.6, -122.3, { operator: 'United' }), record('a2', 37.61, -122.3, { operator: 'Delta' }), record('a3', 37.62, -122.3, { operator: 'United', baroAltitudeM: 3000 }), - record('a4', 37.63, -122.3, { onGround: true, baroAltitudeM: 0 }), + record('a4', 37.63, -122.3, { + onGround: true, + baroAltitudeM: 0, + speedMps: 0, + }), record('a5', 51.5, -0.1, { operator: 'British Airways' }), ]; const catalog = composeCatalog({ @@ -422,15 +426,44 @@ test('aircraft in an area can be counted by operator over every match', async () other_count: 0, unknown_count: 1, }); - // Altitude covers airborne aircraft only; speed covers every match. - assert.deepEqual(result.data.stats.altitude_m, { - count: 3, - min: 3000, - median: 10000, - mean: 7666.7, - max: 10000, + // Both figures cover airborne aircraft only: the parked a4 stands at 0 m + // and 0 m/s and would pull each mean down. + assert.deepEqual(result.data.stats, { + altitude_m: { + count: 3, + min: 3000, + median: 10000, + mean: 7666.7, + max: 10000, + }, + speed_mps: { count: 3, min: 230, median: 230, mean: 230, max: 230 }, }); - assert.equal(result.data.stats.speed_mps.count, 4); +}); + +test('aircraft stats are empty when every match is on the ground', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed([ + record('g1', 37.6, -122.3, { + onGround: true, + baroAltitudeM: 0, + speedMps: 0, + }), + record('g2', 37.61, -122.3, { + onGround: true, + baroAltitudeM: 0, + speedMps: 4, + }), + ]), + }, + }); + const result = await catalog.call('aircraft_in_area', { + area: bay, + stats: true, + }); + assert.match(result.summary, /^2 aircraft in /); + assert.deepEqual(result.data.stats, { altitude_m: null, speed_mps: null }); }); test('aircraft answers carry no breakdown or stats unless asked', async () => { diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index dfa1fd761..d86bdf352 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -297,6 +297,60 @@ test('numeric AIS ship-type codes group and filter by their family', async () => ); }); +test('reserved AIS ship-type codes 100 to 255 count as unknown', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { type: '100' }), + vessel('366000002', 37.79, -122.41, { type: '255' }), + vessel('366000003', 37.77, -122.39, { type: '70' }), + ], + }), + }, + }); + const result = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.deepEqual(result.data.breakdown.groups, [ + { value: 'CARGO', count: 1 }, + ]); + assert.equal(result.data.breakdown.unknown_count, 2); + assert.doesNotMatch(result.summary, /\b(100|255) \(/); +}); + +test('find_vessel answers carry normalized types and destinations', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000001', 37.78, -122.4, { + type: '71', + destination: '---', + }), + vessel('366000002', 37.79, -122.41, { + type: '0', + destination: 'NLRTM', + }), + ], + }), + }, + }); + const cargo = await catalog.call('find_vessel', { mmsi: '366000001' }); + assert.deepEqual( + cargo.data.rows.map((row) => [row.type, row.destination]), + [['CARGO', null]], + ); + const unknown = await catalog.call('find_vessel', { mmsi: '366000002' }); + assert.deepEqual( + unknown.data.rows.map((row) => [row.type, row.destination]), + [[null, 'NLRTM']], + ); +}); + test('a destination with no letters counts as unknown', async () => { const catalog = composeCatalog({ tools: coreTools, From c174742e2c7e7828156ef78bf9f5d7ab84e08628 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:32:58 +0100 Subject: [PATCH 33/35] fix(cctv): FL511 written-consent wording and robust catalog paging - State FL511's terms the same way everywhere: individual, personal use only, and any other use (a public deployment included) needs FDOT's express written consent. .env.example, README, dataCredits, the source licence string, the catalog comment and the changelog fragment said only "individual, non-commercial use". - Page the ArcGIS catalog on exceededTransferLimit alone and advance the offset by the rows each page returned, so a lower server-side max record count no longer cuts the catalog short at the first short page. - Order pages by OBJECTID_1 (this layer's object id; a plain OBJECTID is rejected with HTTP 400) so offset paging is stable. - Raise the page ceiling to 40 and warn when it is hit. - Tests: short pages with exceededTransferLimit keep paging, orderByFields is sent, and a service that ignores resultOffset stops at the ceiling. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- DATA_SOURCES.md | 4 +-- README.md | 2 +- changelog.d/605-fl511-florida-cameras.md | 5 +-- server/providers/cctv/catalog.js | 5 +-- server/providers/cctv/constants.js | 2 ++ server/providers/cctv/sources.js | 30 +++++++++++----- src/data/cctvFlorida.test.mjs | 46 ++++++++++++++++++++++-- src/data/dataCredits.js | 5 +-- 9 files changed, 80 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 9edc74f0a..cbf2941bd 100644 --- a/.env.example +++ b/.env.example @@ -237,7 +237,7 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required -# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, non-commercial use only; see DATA_SOURCES.md) +# CCTV_FLORIDA_ENABLED= # off by default; set 1 to load FL511 (Florida) cameras (individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent; see DATA_SOURCES.md) # CCTV_FLORIDA_MAX_SOURCES=500 # cap cameras loaded from the FL511 catalog (nearest regional anchors first) # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6afc12f4a..3fc0cd08b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -48,7 +48,7 @@ How to read this: | **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain | | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) | -| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual non-commercial use only ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) | +| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) | | **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) | | **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) | | **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) | @@ -161,7 +161,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. -- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged 2000 rows at a time to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, non-commercial use only; deployment beyond personal use needs FDOT's written consent. +- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. diff --git a/README.md b/README.md index b6600ea48..a4cc32acd 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual non-commercial use only). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md index ecb65553a..76f840198 100644 --- a/changelog.d/605-fl511-florida-cameras.md +++ b/changelog.d/605-fl511-florida-cameras.md @@ -1,8 +1,9 @@ - Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: the statewide ArcGIS catalog, read a page at a time under a byte cap with redirects refused, and frames pinned to FL511's two image hosts over HTTPS. - FL511's terms limit its content to individual, non-commercial use, so the - pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + FL511's terms limit its content to individual, personal use, and any other + use (a public deployment included) needs FDOT's express written consent, so + the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the catalog's direction codes, and camera ids come from the image channel, since the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` sets the cap (Danielslay86, #605). diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 91e5336cf..0e69ac3c9 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -146,8 +146,9 @@ const LIVE_PACKS = [ load: loadLithuaniaSources, }, { - // FL511's terms limit its content to individual, non-commercial use, so - // the pack is off unless the operator opts in (see DATA_SOURCES.md). + // FL511's terms limit its content to individual, personal use (other use + // needs FDOT's written consent), so the pack is off unless the operator + // opts in (see DATA_SOURCES.md). name: 'florida', enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'), load: loadFloridaSourcesFromOpenData, diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 26d8b43b8..6ac545028 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -122,6 +122,8 @@ export const FLORIDA_IMAGE_HOSTS = [ export const DEFAULT_FLORIDA_MAX_SOURCES = 500; /** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */ export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */ +export const FLORIDA_MAX_CATALOG_PAGES = 40; export const FLORIDA_ANCHORS = [ { lat: 25.7617, lon: -80.1918 }, // Miami { lat: 28.5384, lon: -81.3789 }, // Orlando diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 91a5aa35e..2e818c046 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -60,6 +60,7 @@ import { DEFAULT_FLORIDA_MAX_SOURCES, FLORIDA_ANCHORS, FLORIDA_MAX_CATALOG_BYTES, + FLORIDA_MAX_CATALOG_PAGES, CATALONIA_CAMERAS_URL, CATALONIA_MAX_CATALOG_BYTES, CATALONIA_IMAGE_HOSTS, @@ -2883,14 +2884,18 @@ export function floridaCameraToSource(feature) { url: imageUrl, snapshotUrl: imageUrl, sourceKind: 'fl511-open-data', - license: 'FL511 (FDOT), individual non-commercial use only', + license: + 'FL511 (FDOT), individual personal use only; other use needs FDOT written consent', }; } /** * Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged - * 2,000 rows at a time. Frames are stills on the Divas Cloud hosts. A redirect - * on any page drops the pack; any other failed page keeps the rows already read. + * up to 2,000 rows at a time, ordered by the layer's object id so offsets are + * stable. Paging follows `exceededTransferLimit` and advances by the rows each + * page returned, so a lower server-side max record count still reads the whole + * layer. Frames are stills on the Divas Cloud hosts. A redirect on any page + * drops the pack; any other failed page keeps the rows already read. * * @returns {Promise>} Normalized camera source objects. */ @@ -2899,9 +2904,17 @@ export async function loadFloridaSourcesFromOpenData() { const cameras = []; const seen = new Set(); const pageSize = 2000; + let offset = 0; // Page ceiling, in case the service ignores resultOffset. - for (let offset = 0, page = 0; page < 10; offset += pageSize, page += 1) { - const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`; + for (let page = 0; ; page += 1) { + if (page >= FLORIDA_MAX_CATALOG_PAGES) { + console.warn( + `[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`, + ); + break; + } + // OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400). + const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`; let resp; try { resp = await fetch(url, { @@ -2953,9 +2966,10 @@ export async function loadFloridaSourcesFromOpenData() { seen.add(camera.id); cameras.push(camera); } - // ArcGIS sets exceededTransferLimit while more rows remain. - if (feats.length < pageSize || body?.exceededTransferLimit !== true) - break; + // ArcGIS sets exceededTransferLimit while more rows remain. A page may + // be shorter than pageSize when the layer's max record count is lower. + if (body?.exceededTransferLimit !== true || feats.length === 0) break; + offset += feats.length; } const maxRaw = Number( diff --git a/src/data/cctvFlorida.test.mjs b/src/data/cctvFlorida.test.mjs index 17cd5284a..6d433a2f2 100644 --- a/src/data/cctvFlorida.test.mjs +++ b/src/data/cctvFlorida.test.mjs @@ -9,6 +9,7 @@ import { import { FLORIDA_CCTV_URL, FLORIDA_MAX_CATALOG_BYTES, + FLORIDA_MAX_CATALOG_PAGES, } from '../../server/providers/cctv/constants.js'; import { fallbackHeadingFromId, @@ -88,7 +89,8 @@ test('an FL511 feature maps to a source on a pinned HTTPS frame host', () => { assert.equal(source.url, `${IMAGE_HOST}chan-9431_h.jpg`); assert.equal(source.url, source.snapshotUrl); assert.equal(source.sourceKind, 'fl511-open-data'); - assert.match(source.license, /non-commercial/); + assert.match(source.license, /personal use only/); + assert.match(source.license, /written consent/); }); test('NOT DIRECTIONAL takes the id-hash fallback at low confidence', () => { @@ -161,7 +163,7 @@ test('isLikelyFloridaCoordinate spans the state and rejects the rest', () => { assert.equal(isLikelyFloridaCoordinate(lat, lon), false); }); -test('the loader pages until a short page and collapses repeated rows', async (t) => { +test('the loader pages until exceededTransferLimit clears and collapses repeated rows', async (t) => { t.mock.method(console, 'log', () => {}); uncapped(t); const calls = []; @@ -170,7 +172,7 @@ test('the loader pages until a short page and collapses repeated rows', async (t calls.push([offsetOf(url), init.redirect]); if (offsetOf(url) === 0) return Response.json(page(0, 2000, { exceededTransferLimit: true })); - // Short last page: two new cameras and a repeat of one already read. + // Last page (no exceededTransferLimit): two new cameras and a repeat. return Response.json(page(1999, 3)); }); const cameras = await loadFloridaSourcesFromOpenData(); @@ -182,6 +184,44 @@ test('the loader pages until a short page and collapses repeated rows', async (t assert.equal(new Set(cameras.map((camera) => camera.id)).size, 2002); }); +test('pages shorter than requested keep paging while the server says more remain', async (t) => { + t.mock.method(console, 'log', () => {}); + uncapped(t); + const offsets = []; + t.mock.method(globalThis, 'fetch', async (url) => { + const params = new URL(String(url)).searchParams; + // Offset paging is only stable over a fixed order on the object id. + assert.equal(params.get('orderByFields'), 'OBJECTID_1'); + const offset = offsetOf(url); + offsets.push(offset); + // A layer whose max record count was lowered to 1,000. + return Response.json( + page(offset, offset < 2000 ? 1000 : 500, { + exceededTransferLimit: offset < 2000, + }), + ); + }); + const cameras = await loadFloridaSourcesFromOpenData(); + assert.deepEqual(offsets, [0, 1000, 2000]); + assert.equal(cameras.length, 2500); +}); + +test('a service that ignores resultOffset stops at the page ceiling with a warning', async (t) => { + t.mock.method(console, 'log', () => {}); + const warnings = []; + t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' '))); + uncapped(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return Response.json(page(0, 2000, { exceededTransferLimit: true })); + }); + const cameras = await loadFloridaSourcesFromOpenData(); + assert.equal(calls, FLORIDA_MAX_CATALOG_PAGES); + assert.equal(cameras.length, 2000); + assert.ok(warnings.some((line) => /page ceiling/.test(line))); +}); + test('a redirect on any page drops the whole pack and releases it', async (t) => { t.mock.method(console, 'warn', () => {}); const redirected = streamingResponse({ diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index deba638e4..781db0643 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -267,8 +267,9 @@ export const DATA_CREDITS = [ key: 'florida-cctv', html: 'Traffic cameras (Florida): FL511, Florida Department of Transportation. ' + - 'Individual, non-commercial use only under the ' + - 'FL511 terms', + 'Individual, personal use only under the ' + + 'FL511 terms; ' + + "any other use needs FDOT's express written consent", }, { key: 'catalonia-cctv', From 0f237a32d476d303121df4ee6bd86512709d3fc4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 15:18:54 +0100 Subject: [PATCH 34/35] fix(agent): age World Packet feeds against the clock for non-finite now A default parameter only covers undefined, so now: null aged every feed against the epoch and reported stale data as just now. Co-Authored-By: Claude Opus 5.5 --- src/agent/worldPacket.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index 0987a7fce..4f8dad144 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -99,8 +99,11 @@ export function getWorldPacket(options = {}) { styleManager, dataManager, sceneDirector = null, - now = Date.now(), + now: requestedNow, } = options ?? {}; + // A default parameter only covers undefined; null or NaN would age every + // feed against the epoch and report stale data as "just now". + const now = Number.isFinite(requestedNow) ? requestedNow : Date.now(); if (!viewer?.camera?.positionWC) throw new TypeError('World Packet requires a viewer camera'); if (!dataManager || typeof dataManager.getAll !== 'function') From daaaa348b7049448335b32ec7dbb3c54d1d0e50e Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 15:18:54 +0100 Subject: [PATCH 35/35] test(agent): pin World Packet to the pre-extraction payload The parity test compared getWorldPacket with voice, which now calls getWorldPacket. Compare both against a fixed payload captured from main's get_current_view_state before the extraction, key order included, and cover a non-finite now. Co-Authored-By: Claude Opus 5.5 --- src/agent/worldPacket.test.mjs | 202 +++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs index c4d0200ba..063e3652c 100644 --- a/src/agent/worldPacket.test.mjs +++ b/src/agent/worldPacket.test.mjs @@ -128,6 +128,208 @@ test('World Packet and voice preserve enabled-only payload and count disabled la assert.deepEqual(withoutVoicePresentation(voice), packet); }); +const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0); + +// Exercises every packet field: context vocabulary and Contacts window, +// cockpit, controls, scene playback, tracked families (one not ready), +// enabled and disabled layers, and an aged and an unavailable feed. +function pinnedHarness() { + const { viewer } = harness(); + const rows = [ + { + id: 'flights', + name: 'Flights', + enabled: true, + stats: { + count: 12, + source: 'fixture-adsb', + lastUpdate: PINNED_NOW - 90_000, + }, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }, + ]; + const module = (methods) => ({ module: methods }); + const dataManager = { + layers: new Map([ + [ + 'flights', + module({ + getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }), + }), + ], + [ + 'ais-live-vessels', + module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }), + ], + [ + 'satellites', + module({ + getTrackedInfo: () => { + throw new Error('not ready'); + }, + }), + ], + [ + 'military-awareness', + module({ + getContextSnapshot: () => ({ snapshot: true }), + contactsWindowFromSnapshot: (snapshot) => + snapshot ? { windowMin: 30, count: 4 } : null, + }), + ], + ]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'night-vision', + getContextModeState: () => ({ + mode: 'flights', + entering: null, + priorMode: null, + }), + getCockpitState: () => ({ active: false }), + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + const sceneDirector = { + getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }), + }; + return { viewer, dataManager, styleManager, sceneDirector }; +} + +// Captured from main's get_current_view_state before the extraction +// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW, +// minus the speech envelope. Key order is part of the contract. +const PRE_EXTRACTION_PAYLOAD = { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: 30.259999999999994, + longitude: -97.74, + heightM: 499.99999999901877, + }, + style: 'night-vision', + context: { + mode: 'contacts', + entering: null, + priorMode: null, + modeInternal: 'flights', + enteringInternal: null, + priorModeInternal: null, + contactsWindow: { windowMin: 30, count: 4 }, + }, + cockpit: { active: false }, + controls: { hud: { visible: 'auto' } }, + scenePlayback: { playing: false, sceneId: 'fixture' }, + tracked: [ + { + kind: 'aircraft', + layerId: 'flights', + icao24: 'abc123', + callsign: 'TEST1', + }, + { kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' }, + ], + disabledLayerCount: 1, + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + count: 12, + error: null, + feedState: 'nominal', + source: 'fixture-adsb', + lastUpdate: 1792238310000, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + count: 0, + error: 'fixture unavailable', + feedState: 'unavailable', + source: 'fixture', + lastUpdate: 0, + }, + ], + feedProvenance: { + overall: 'unavailable', + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + feedState: 'nominal', + source: 'fixture-adsb', + count: 12, + lastUpdate: 1792238310000, + ageSec: 90, + ageLabel: '1m ago', + error: null, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + feedState: 'unavailable', + source: 'fixture', + count: 0, + lastUpdate: 0, + ageSec: null, + ageLabel: null, + error: 'fixture unavailable', + }, + ], + note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.', + }, +}; + +function assertPinnedPayload(actual) { + assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD); + // deepEqual ignores key order; the serialized form does not. + assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD)); +} + +test('World Packet matches the pre-extraction get_current_view_state payload', () => { + const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW }); + assertPinnedPayload(packet); +}); + +test('voice get_current_view_state still returns the pre-extraction payload', async (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + const runner = createGevActionRunner(pinnedHarness()); + const voice = await runner('get_current_view_state'); + assertPinnedPayload(withoutVoicePresentation(voice)); +}); + +test('World Packet ages feeds against the clock when now is not a finite number', (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + for (const now of [null, Number.NaN, '2026-10-17']) { + const packet = getWorldPacket({ ...pinnedHarness(), now }); + const flights = packet.feedProvenance.layers.find( + (layer) => layer.id === 'flights', + ); + assert.equal(flights.ageSec, 90, `now: ${String(now)}`); + assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`); + } +}); + test('World Packet rejects null options with the camera requirement', () => { assert.throws(() => getWorldPacket(null), /requires a viewer camera/); });