From d04939b813da2e9d5f4a0bc3e056616d78a57c90 Mon Sep 17 00:00:00 2001 From: Gaurav Patidar Date: Wed, 9 Sep 2026 13:23:59 +0530 Subject: [PATCH 01/70] fix:docs --- CONTRIBUTING.md | 19 +++++++++++++++++++ DATA_SOURCES.md | 30 ++++++++++++++++++++++++++++++ docs/KNOWN-ISSUES.md | 6 +++--- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6760dfdf1..029b53514 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,25 @@ through Pinokio; the terminal path above remains the contributor path. Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.** +### Test prerequisites + +- **`npm test`** — unit tests. Runs standalone; no dev server or credentials + required. Exit code is the gate. +- **`npm run test:track`** — the 3D flight-tracking regression harness. It + drives the **real app** in headless Chromium, so it **requires a running + dev server** on the default port (`http://localhost:4173`). Pass + `--url ` to point it at another instance (e.g. + `node scripts/track-regression.mjs --url http://localhost:4272`). It does not + depend on live OpenSky / adsb.lol / AISStream (those are optional or + rate-limited locally); it installs a persistent `fetch` shim that returns + synthetic aircraft in the exact upstream payload shapes the layers parse. +- **`npm run build`** — production Vite build. No server or credentials + required; it validates that the client bundle compiles cleanly. + +If you change runtime behavior, update `docs/CURRENT-STATE.md` and +`CHANGELOG.md` in the same PR. For a deeper catalog of what each harness +checks and what it needs to run, see [TESTING.md](TESTING.md). + ## Good first contributions The highest-leverage places to jump in: diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index faa351dca..bb0626117 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -125,6 +125,36 @@ the retrieval date (2026-07-30), exact download URL, license evidence, and the deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m Douglas-Peucker simplification, 6-decimal rounding). +### Pilot CCTV source packs (`config/`) + +`config/cctv_sources.shinjuku.json` is a **demo source pack** (3 cameras: +Shinjuku Crossing, Shinjuku Station West, Shibuya Scramble) used for +projection-pipeline testing. The feeds are Google-hosted sample MP4 videos +(`storage.googleapis.com/gtv-videos-bucket/sample/`), not live public cameras. +Camera poses (coordinates, heading, pitch, FOV, mount height) are fabricated +priors placed at recognizable Tokyo intersections. + +Use the pack by setting: + +```bash +CCTV_SOURCES_FILE=config/cctv_sources.shinjuku.json npm run dev +``` + +Or equivalently, via `./scripts/dev-cctv.sh`: + +```bash +CCTV_SOURCES_FILE=config/cctv_sources.shinjuku.json ./scripts/dev-cctv.sh +``` + +The pack exercises the same CCTV registration and frustum-projection pipeline +as live city packs without requiring network access to a camera catalog. It is +**not** a live data source and carries no external attribution requirement. The +sample videos are publicly available Google test media. + +`config/cctv_sources.austin.json` is the reference live pack. Custom source +packs follow the same JSON schema (see either file); `CCTV_SOURCES_JSON` +accepts inline JSON as an alternative to a file path. + --- ## In-app attribution diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 33c665f39..ca46d2ef0 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,9 @@ Status: Open (owner-accepted 2026-07-08, documented) a few stragglers take one more poll. - **Born-grounded first poll:** a contact first seen on the ground with no altitude data renders at the geoid for ≤1 poll until its floor cell warms. -- Full context, improvement ideas, and the verification oracle - (`scripts/qa-floor-verify.mjs`): - `docs/superpowers/reports/2026-07-08-height-datum-handover.md`. +- Verify floor-placement behavior with `scripts/qa-floor-verify.mjs` against a + running local app; set `QA_BASE_URL` when it is not on the default port + (`http://localhost:4173`). --- From e3772f806f660ade51817707208250c7b7dd9a51 Mon Sep 17 00:00:00 2001 From: Gaurav Patidar Date: Fri, 11 Sep 2026 00:55:57 +0530 Subject: [PATCH 02/70] Clarify test:track command usage in CONTRIBUTING.md Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 029b53514..eef422a84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,9 +33,8 @@ Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test required. Exit code is the gate. - **`npm run test:track`** — the 3D flight-tracking regression harness. It drives the **real app** in headless Chromium, so it **requires a running - dev server** on the default port (`http://localhost:4173`). Pass - `--url ` to point it at another instance (e.g. - `node scripts/track-regression.mjs --url http://localhost:4272`). It does not + dev server** (default `http://localhost:4173`). To target another instance, + run `npm run test:track -- --url ` (or `node scripts/track-regression.mjs --url `). depend on live OpenSky / adsb.lol / AISStream (those are optional or rate-limited locally); it installs a persistent `fetch` shim that returns synthetic aircraft in the exact upstream payload shapes the layers parse. From 8e68a29c5079a58fe133dd4589ea0ec764d4b701 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:55 -0700 Subject: [PATCH 03/70] fix(cables): preserve feature ownership across multipart geometry Associate expanded cable and landing entities through a copied source index, retain one reference stem per feature, and cover duplicate IDs, bundled segments and frozen provider inputs with real Cesium regressions. --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 8 + .../telegeographySubmarineCables.test.mjs | 249 ++++++++++++++++++ src/layers/submarineCables/ingestion.js | 34 ++- 4 files changed, 291 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..f45d71d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature. + - 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 26d56e66b..33e4c36b4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5002,6 +5002,14 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. +Multipart cable and landing-point geometry retains its source feature on every +Cesium entity, including generated IDs and IDs that collide with Cesium's +multipart suffixes. Each segment receives its own feature's styling, click +destination and horizon-culling reference; each source feature has one reference +stem. Source collections and their properties are not modified. The copied input +adds `__gevSourceFeatureIndex`, which remains observable in Cesium properties +and default descriptions. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index eaefbeb39..3779af9c9 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -5,6 +5,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -48,7 +63,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -60,7 +75,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -130,7 +145,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -145,6 +165,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -154,7 +176,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -169,6 +193,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), From a856d54bba46424571e40227997eb576c9351396 Mon Sep 17 00:00:00 2001 From: John Leshan Date: Sat, 17 Oct 2026 13:07:04 +0300 Subject: [PATCH 04/70] docs: add cloud setup path via GitHub Codespaces --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c49c53ece..75f796724 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,38 @@ the container. +### Path 4 — GitHub Codespaces (cloud, no local GPU) + +Runs the same dev server in a cloud workspace, so integrated graphics and a +local GPU never come into it. GitHub Free includes 120 core-hours a month with +no card on file — about 60 hours on the default 2-core machine. Org and +Enterprise plans include no free quota; current terms and quotas apply. + +1. Fork this repository, then open **your fork** on GitHub. +2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. +3. In the codespace terminal: + ```bash + nvm install 24 && nvm use 24 + npm ci + npm run doctor + GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev + ``` +4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in + the browser. The port is private by default, so the URL asks for your GitHub + login. + +`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only +the hostnames it trusts, and a forwarded port arrives on +`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the +page is `Blocked request. This host is not allowed.` Leading-dot wildcards are +ignored on purpose, so pass that exact name. + +⚠️ **A codespace is reachable from the internet.** Treat it as the +wider-publish case in [Sharing an instance](#-sharing-an-instance): leave +Provider Settings closed to strangers, and put keys in the workspace `.env` by +hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +threat model. + ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** From 295e164fc3dac706ad51a47013e41e4f95441bd8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:31:09 +0100 Subject: [PATCH 05/70] docs(readme): say Codespaces moves the server, not the rendering The globe renders in the viewer's own browser, so a codespace does not remove the need for local graphics. Retitle Path 4 accordingly. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0e1f6ffe7..ce2e97ab9 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,14 @@ the container. -### Path 4 — GitHub Codespaces (cloud, no local GPU) +### Path 4 — GitHub Codespaces (cloud, no local install) -Runs the same dev server in a cloud workspace, so integrated graphics and a -local GPU never come into it. GitHub Free includes 120 core-hours a month with -no card on file — about 60 hours on the default 2-core machine. Org and -Enterprise plans include no free quota; current terms and quotas apply. +Runs the same dev server in a cloud workspace, so Node and the dependencies +never touch your machine. The globe still renders in your own browser, so it +needs the same graphics there as any other path. GitHub Free includes 120 +core-hours a month with no card on file — about 60 hours on the default 2-core +machine. Org and Enterprise plans include no free quota; current terms and +quotas apply. 1. Fork this repository, then open **your fork** on GitHub. 2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. From 58a41759b52e795268e1be6cb2d1fa84547b2630 Mon Sep 17 00:00:00 2001 From: Aliciawque <166712943+Aliciawque@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:59 +0800 Subject: [PATCH 06/70] test: resolve sky texture file URLs on Windows --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); From ea1b0d8d5506722e5672d685b5b0fe251e6ce3d0 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:54:36 +0530 Subject: [PATCH 07/70] fix(voice): leave out an unreported altitude instead of saying 0 feet select_nearest_aircraft passes altitudeM: null for an airborne contact without a barometric altitude (military rows from adsb.lol can lack alt_baro). The speech builder coerced it with Number(), and Number(null) is 0, so the reply said "..., 0 feet." and the voice card showed "Altitude 0 feet". Read the altitude only when it is a finite number, so an unknown one is left out as the builder already intends. --- docs/CURRENT-STATE.md | 2 ++ src/voice/speech.js | 6 +++--- src/voice/speech.test.mjs | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..2a77faaf0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5115,6 +5115,8 @@ and unreachable upstream (502/504) separately from road geometry. cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is queried normally and its source is returned with the selection; fallback with no airborne records remains an honest no-data result, not an enable failure. + The spoken reply and the card leave out an altitude the feed did not report + rather than reading it as 0 feet. - Voice treats the parent Context panel and Contacts as separate intents. An explicit request to open Context expands only `global-context-panel`; it does not choose a mode. An explicit request to open Contacts expands that parent diff --git a/src/voice/speech.js b/src/voice/speech.js index c53881595..d698d863e 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -74,9 +74,9 @@ function listPhrase(items) { } function feetRounded(meters) { - const value = Number(meters); - return Number.isFinite(value) - ? Math.round((value * 3.28084) / 100) * 100 + // A missing altitude is unknown, not sea level: Number(null) is 0. + return Number.isFinite(meters) + ? Math.round((meters * 3.28084) / 100) * 100 : null; } diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index 36482290d..b280b2ebc 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -217,6 +217,20 @@ test('nearest-aircraft results name the aircraft, distance and altitude briefly' assert.equal(stale.say, 'Selected AAL1, 3.3 km from Austin, stale.'); }); +test('nearest-aircraft results leave out an altitude the feed did not report', () => { + // gevActions sends `altitudeM: null` for an airborne military contact + // without a barometric altitude; that is unknown, not sea level. + const unknown = envelope('select_nearest_aircraft', { + ok: true, + location: 'Ramstein', + layerId: 'military', + feed: { state: 'nominal' }, + aircraft: { callsign: 'RCH123', altitudeM: null, distanceKm: 8.2 }, + }); + assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.'); + assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']); +}); + test('frame_overhead keeps the essential scope audible and lists referents', () => { const framed = envelope('frame_overhead', { ok: true, From 83c4388e6412724bc5bb96394b50a5a2a6ca387c Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:55:30 +0530 Subject: [PATCH 08/70] fix(awareness): show a 999.6 m contact marker as 1.0 km, not 1000 m The on-globe Contacts markers picked metres before rounding, so a contact 999.5 m or more away read "1000 m" while the Contacts panel read "1.0 km". The marker label now rounds first, as the panel does. --- docs/CURRENT-STATE.md | 5 +++-- src/data/militaryAwarenessEngine.js | 6 ++++-- src/data/militaryAwarenessEngine.test.mjs | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..71803280c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4461,8 +4461,9 @@ silent for five minutes are removed. Credits appear when the feed is used. coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. -Contacts distance labels select metres or kilometres after rounding to the -nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`. +Contacts distance labels, in the panel and on the globe's contact markers, +select metres or kilometres after rounding to the nearest metre, so a +999.5-metre contact displays as `1.0 km`, not `1000 m`. At global scale, ambient Radio cluster badges are hard-opacity shared-host entries: count/category updates and identity replacement do not run keyhole or diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 0f889d53f..059d6d437 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -47,8 +47,10 @@ export function findByDoublingRadius( /** @param {number} meters @returns {string} */ export function formatAwarenessDistance(meters) { if (!Number.isFinite(meters) || meters < 0) return '—'; - return meters < 1000 - ? `${Math.round(meters)} m` + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const wholeMeters = Math.round(meters); + return wholeMeters < 1000 + ? `${wholeMeters} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`; } diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 3415fd5d7..2fc57d0f6 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -24,6 +24,12 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid assert.equal(formatAwarenessLabel(undefined), '—'); }); +test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => { + assert.equal(formatAwarenessDistance(999.4), '999 m'); + assert.equal(formatAwarenessDistance(999.6), '1.0 km'); + assert.equal(formatAwarenessDistance(4600), '4.6 km'); +}); + test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => { const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]); assert.equal(result.relationship, 'NEARBY'); From 4f7b1bc01594cd57b75664e6840fb6352823808d Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:45:33 +0530 Subject: [PATCH 09/70] fix(military): read a GNSS-sourced vertical rate when no baro rate is sent readsb reports an aircraft's vertical rate as geom_rate instead of baro_rate when its source is GNSS. The military normaliser read only baro_rate, so about one in six live /v2/mil contacts had a null vertical rate in analyst and tracked-aircraft data. Fall back to geom_rate, as the adsb.lol flights fallback already does; a sent baro_rate still wins. --- docs/CURRENT-STATE.md | 3 ++- src/sources/live/aircraft.js | 3 ++- src/sources/live/contract.test.mjs | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..c5d66ce8e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1058,7 +1058,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries `verticalRate` and military records as `verticalRateMps`; the query seam maps both to one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The rate is reported, never derived: a blank poll keeps the last transmitted value and an -absent rate stays null rather than reading as level flight. +absent rate stays null rather than reading as level flight. A military readsb row +without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead. ## Transponder emergency highlighting diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 1af1f6b47..9da4f7a5f 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -48,7 +48,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom); const speedKts = finite(row.gs), - rateFtMin = finite(row.baro_rate); + // readsb sends a GNSS-sourced rate as geom_rate instead of baro_rate. + rateFtMin = finite(row.baro_rate) ?? finite(row.geom_rate); const seen = finite(row.seen), seenPos = finite(row.seen_pos); return { diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index b8b1787da..79626f6b3 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -145,6 +145,20 @@ test('readsb units and position/contact ages are normalized once against the sou ); }); +test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => { + // readsb reports the rate under geom_rate when the aircraft's source is + // GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that. + const row = { hex: 'ae638d', lat: 20.3, lon: -158.5, geom_rate: -2432 }; + assert.equal( + normalizeReadsbAircraft(row, now).verticalRateMps, + -2432 * 0.00508, + ); + assert.equal( + normalizeReadsbAircraft({ ...row, baro_rate: -2400 }, now).verticalRateMps, + -2400 * 0.00508, + ); +}); + test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => { const row = normalizeVesselObservation( { From accb8983ca0fd55eeb0c475612d1cfa5457b911d Mon Sep 17 00:00:00 2001 From: Sunil56224972 Date: Tue, 27 Oct 2026 18:02:32 +0530 Subject: [PATCH 10/70] fix(security): add cross-site gate to military-installations proxy The /api/military-installations endpoint was the last keyless proxy without the admitSameSite gate that protects terrain, CCTV, traffic, places, and Celestrak. A cross-site , foreign Origin, opaque origin, or reverse-proxy-forwarded request now receives 403 before any Overpass query. Same-origin app requests and bare tool requests continue to work. Adds 4 integration tests (cross-site refused, same-origin admitted, bare tool admitted, proxied refused) following the terrainHeightsBounds test pattern. --- ...-military-installations-cross-site-gate.md | 6 + pr-body.md | 75 +++++++++ scripts/package-boundaries.json | 4 +- server/providers/military-installations.js | 2 + src/militaryInstallationsGate.test.mjs | 147 ++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1427-military-installations-cross-site-gate.md create mode 100644 pr-body.md create mode 100644 src/militaryInstallationsGate.test.mjs diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md new file mode 100644 index 000000000..3665da022 --- /dev/null +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -0,0 +1,6 @@ +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site browser requests with the same `admitSameSite` gate that protects + the terrain, CCTV, traffic, places, and Celestrak proxies. A cross-site + ``, a foreign `Origin`, an opaque origin, or a reverse-proxy-forwarded + request receives 403 before any Overpass query runs. Same-origin app requests + and bare tool requests (curl, Node harness) continue to work. (Sunil56224972) diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..28e73decc --- /dev/null +++ b/pr-body.md @@ -0,0 +1,75 @@ +## Summary + +Adds the `admitSameSite` cross-site gate to `/api/military-installations`, as offered in the [#975 merge comment](https://github.com/bilawalsidhu/gods-eye-view/pull/975#issuecomment-6357981946). + +This was the last keyless proxy endpoint without the gate. After this change, every proxy that sends queries upstream now refuses cross-site browser requests before doing any work. + +| Endpoint | Gate before this PR | Gate after | +|----------|-------------------|------------| +| `/api/terrain/heights` | ✅ admitSameSite (#975) | ✅ | +| `/api/cctv/frame/:id` | ✅ admitSameSite (#922) | ✅ | +| `/api/traffic/*` | ✅ admitSameSite | ✅ | +| `/api/google/*` | ✅ admitSameSite | ✅ | +| `/api/gemini/*` | ✅ admitSameSite | ✅ | +| `/api/space/satellites` | ✅ admitSameSite | ✅ | +| `/api/overpass` | ✅ admitSameSite | ✅ | +| `/api/military-installations` | ❌ **none** | ✅ admitSameSite | + +## The problem + +A cross-site page (or an `` tag from any website) can query `/api/military-installations` and trigger upstream Overpass requests on the user's behalf. The endpoint has a rate limiter (90/min), but no cross-site gate — so a hostile page can burn through the user's Overpass quota and exfiltrate the response data. + +Every other proxy in the codebase already refuses these shapes. Military-installations was the gap. + +## The fix + +Two lines in `server/providers/military-installations.js`: + +```diff + import { makeRateLimiter, clientKey } from './common/rate-limit.js'; ++import { admitSameSite } from './common/same-site.js'; +``` + +```diff + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } ++ if (admitSameSite(req, res)) return; + const url = new URL(req.url, 'http://localhost'); +``` + +Same pattern as terrain.js, cctv.js, traffic.js, google.js, etc. Gate runs before the rate limiter, so refused requests never consume quota. + +`scripts/package-boundaries.json` updated to declare `server/providers/common/same-site.js` and `src/localRequestGate.mjs` as owned modules of `military-installations-provider`. + +## Tests + +New file: `src/militaryInstallationsGate.test.mjs` — 4 integration tests following the `terrainHeightsBounds.test.mjs` pattern: + +``` +✔ cross-site browser requests are refused with 403 (2.4ms) +✔ same-origin browser request is admitted (not 403) (5.3ms) +✔ bare tool request (no Origin, no Sec-Fetch) is admitted (0.9ms) +✔ proxied same-origin requests are refused (proxy headers present) (0.5ms) +``` + +Tested shapes: +- `Sec-Fetch-Site: cross-site` (the `` case) → 403 +- `Sec-Fetch-Site: same-site` (cross-origin sibling) → 403 +- `Origin: https://evil.example` (fetch from hostile page) → 403 +- `Origin: null` (sandboxed iframe) → 403 +- `X-Forwarded-For` + `Sec-Fetch-Site: cross-site` (proxied cross-site) → 403 +- Same-origin with matching `Origin` → admitted +- No `Origin`, no `Sec-Fetch` (curl / Node) → admitted +- Proxy headers with `same-origin` → 403 + +All 4 tests fail on `main` and pass with the fix. + +## Validation + +- `node --test src/localRequestGate.test.mjs src/militaryInstallationsGate.test.mjs`: **18 tests, 18 pass, 0 fail** +- `npm run check:boundaries`: **pass** (military-installations-provider: 15 owned modules) +- `npm run check:import-directions`: pass +- Changelog fragment in `changelog.d/military-installations-cross-site-gate.md` diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..4219b311c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -298,7 +298,9 @@ "server/providers/overpass/transport.js", "src/sources/httpBody.js", "src/i18n/marker.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 7d252b8ed..1b6c82107 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -1,4 +1,5 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, @@ -97,6 +98,7 @@ function militaryInstallationsProxy() { res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } + if (admitSameSite(req, res)) return; const url = new URL(req.url, 'http://localhost'); const requested = validMilitaryInstallationBox(url.searchParams); if (!requested) { diff --git a/src/militaryInstallationsGate.test.mjs b/src/militaryInstallationsGate.test.mjs new file mode 100644 index 000000000..4451c384b --- /dev/null +++ b/src/militaryInstallationsGate.test.mjs @@ -0,0 +1,147 @@ +// Military-installations cross-site gate — verifies that the admitSameSite +// gate added in this PR actually refuses the same request shapes the terrain +// and CCTV proxies already refuse, and admits same-origin and bare-tool +// requests. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { militaryInstallationsProxy } from '../server/providers/military-installations.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request( + handler, + { url = '/', method = 'GET', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, +) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { + method, + url, + headers: reqHeaders, + socket: { remoteAddress }, + }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +// A valid bbox query string for military-installations. +const VALID_BBOX = '?south=48.8&west=2.2&north=48.9&east=2.4'; +const HOST = 'localhost:5173'; + +test('cross-site browser requests are refused with 403', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + const refused = [ + // from any web page + { host: HOST, 'sec-fetch-site': 'cross-site' }, + // same-site but cross-origin sibling + { host: HOST, 'sec-fetch-site': 'same-site' }, + // fetch from another page + { host: HOST, origin: 'https://evil.example' }, + // sandboxed frame + { host: HOST, origin: 'null' }, + // behind a reverse proxy with cross-site + { host: HOST, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, + ]; + for (const reqHeaders of refused) { + const answer = await request(route, { url: VALID_BBOX, reqHeaders }); + assert.equal( + answer.statusCode, + 403, + `expected 403 for headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, + ); + } +}); + +test('same-origin browser request is admitted (not 403)', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + // Same-origin with matching origin header + const answer = await request(route, { + url: VALID_BBOX, + reqHeaders: { + host: HOST, + origin: `http://${HOST}`, + 'sec-fetch-site': 'same-origin', + }, + }); + // Should get past the gate — may be 200, 429, or 503 depending on Overpass config, + // but never 403. + assert.notEqual(answer.statusCode, 403, 'same-origin request should not be refused'); +}); + +test('bare tool request (no Origin, no Sec-Fetch) is admitted', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + // curl / Node harness — no browser headers at all + const answer = await request(route, { + url: VALID_BBOX, + reqHeaders: { host: HOST }, + }); + assert.notEqual(answer.statusCode, 403, 'bare tool request should not be refused'); +}); + +test('proxied same-origin requests are refused (proxy headers present)', async () => { + const route = install(militaryInstallationsProxy()).get( + '/api/military-installations', + ); + const proxied = [ + { host: HOST, 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + { host: HOST, 'sec-fetch-site': 'same-origin', 'cf-connecting-ip': '203.0.113.9' }, + { + host: HOST, + 'sec-fetch-site': 'same-origin', + forwarded: 'for=203.0.113.9;proto=https', + via: '1.1 caddy', + }, + ]; + for (const reqHeaders of proxied) { + const answer = await request(route, { url: VALID_BBOX, reqHeaders }); + assert.equal( + answer.statusCode, + 403, + `expected 403 for proxied headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, + ); + } +}); From 7a1a9cc7064707651921eee7f790bca5bd81fc88 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:20 +0530 Subject: [PATCH 11/70] docs(changelog): add #1433 fragment --- changelog.d/1433-awareness-marker-kilometre.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1433-awareness-marker-kilometre.md diff --git a/changelog.d/1433-awareness-marker-kilometre.md b/changelog.d/1433-awareness-marker-kilometre.md new file mode 100644 index 000000000..86cfbd431 --- /dev/null +++ b/changelog.d/1433-awareness-marker-kilometre.md @@ -0,0 +1,3 @@ +- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker + chose metres before rounding, so 999.6 m read "1000 m" while the panel read + "1.0 km" (Aditya, @Rohilalala, #1433). From 5d9264e65f4c4c8d2e527a0e2ad9e1ab1ab4739a Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:27 +0530 Subject: [PATCH 12/70] docs(changelog): add #1434 fragment --- changelog.d/1434-voice-unknown-altitude.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1434-voice-unknown-altitude.md diff --git a/changelog.d/1434-voice-unknown-altitude.md b/changelog.d/1434-voice-unknown-altitude.md new file mode 100644 index 000000000..229d31800 --- /dev/null +++ b/changelog.d/1434-voice-unknown-altitude.md @@ -0,0 +1,3 @@ +- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact + whose feed reported no altitude; the reply and card leave the altitude out + (Aditya, @Rohilalala, #1434). From ffbb0f9d6512346b0424c64930a9335632a78746 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:57:42 +0530 Subject: [PATCH 13/70] docs(changelog): add #1436 fragment --- changelog.d/1436-military-geom-vertical-rate.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1436-military-geom-vertical-rate.md diff --git a/changelog.d/1436-military-geom-vertical-rate.md b/changelog.d/1436-military-geom-vertical-rate.md new file mode 100644 index 000000000..ddeb5b4aa --- /dev/null +++ b/changelog.d/1436-military-geom-vertical-rate.md @@ -0,0 +1,3 @@ +- Military aircraft whose transponders report a GNSS vertical rate now carry it + in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala, + #1436). From 3321affaa6351a5771018b21c966b5c22e3ed810 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:02:55 +0530 Subject: [PATCH 14/70] test(voice): check a sea-level altitude is still spoken --- src/voice/speech.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index b280b2ebc..345823064 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -229,6 +229,20 @@ test('nearest-aircraft results leave out an altitude the feed did not report', ( }); assert.equal(unknown.say, 'Selected RCH123, 8.2 km from Ramstein.'); assert.deepEqual(unknown.display.lines, ['8.2\u00a0km from Ramstein']); + + // A reported sea-level altitude is still spoken. + const seaLevel = envelope('select_nearest_aircraft', { + ok: true, + location: 'Ramstein', + layerId: 'military', + feed: { state: 'nominal' }, + aircraft: { callsign: 'RCH123', altitudeM: 0, distanceKm: 8.2 }, + }); + assert.equal(seaLevel.say, 'Selected RCH123, 8.2 km from Ramstein, 0 feet.'); + assert.deepEqual(seaLevel.display.lines, [ + '8.2\u00a0km from Ramstein', + 'Altitude 0 feet', + ]); }); test('frame_overhead keeps the essential scope audible and lists referents', () => { From e244703f0812a492a880fd422db40f480b76295c Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:10:57 +0530 Subject: [PATCH 15/70] fix(bikeshare): re-derive a station's missing capacity on every poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A station whose feed publishes no capacity froze its capacity at the first status poll's bikes + docks, or at the placeholder 15 when that poll had no row for it, so later cards read "10 avail · 20 docks · 15 cap" and coloured by the wrong ratio. Each poll now resolves it afresh. --- docs/CURRENT-STATE.md | 2 + src/layers/bikeshare/proxyRoute.test.mjs | 59 ++++++++++++++++++++++++ src/layers/bikeshare/rendering.js | 4 +- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..f91ae76b6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1779,6 +1779,8 @@ limit, as well as when a tile decodes or the source resets. GBFS null or empty availability counts retain an unknown value through station parsing and rendering; they use the neutral station color rather than reporting an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. +A station whose `station_information` publishes no capacity takes bikes plus +docks from each status poll (15 until its status arrives), not from the first. ## Terrain height cache bound diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index e4153a203..718dc5034 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -446,3 +446,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col assert.equal(model.toNonNegativeInteger('3'), 3); } }); + +test('a station without a published capacity re-derives it from every status poll', async () => { + const { createRendering } = await import('./rendering.js'); + const { COLOR_YELLOW } = await import('./policy.js'); + const model = createModel({}); + const state = { + _cityRuntime: new Map(), + _stationRenderMap: new Map(), + _pointCollection: { add: (options) => ({ ...options }) }, + }; + const rendering = createRendering({ + state, + services: { render: { governorRequestRender() {} } }, + parts: { model }, + }); + const info = model.parseStationInformation({ + data: { + stations: [ + { station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 }, + { + station_id: 'fixed', + name: 'Fixed', + lat: 30.27, + lon: -97.75, + capacity: 13, + }, + ], + }, + }); + rendering.ensureCityPoints('austin', info); + const poll = (stations) => + rendering.applyStatusToPoints( + 'austin', + model.parseStationStatus({ data: { stations } }), + ); + const open = state._stationRenderMap.get('austin:open'); + const fixed = state._stationRenderMap.get('austin:fixed'); + + // The first poll has no row for the station: capacity is a placeholder. + poll([ + { + station_id: 'fixed', + num_bikes_available: 4, + num_docks_available: 9, + }, + ]); + poll([ + { station_id: 'open', num_bikes_available: 10, num_docks_available: 20 }, + { station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 }, + ]); + assert.equal(open.capacity, 30, 'the first poll fixed the capacity'); + assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty'); + assert.equal(fixed.capacity, 13, 'a published capacity is kept'); + + poll([ + { station_id: 'open', num_bikes_available: 4, num_docks_available: 2 }, + ]); + assert.equal(open.capacity, 6); +}); diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js index f0c737d1d..c75189d4a 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/rendering.js @@ -185,6 +185,7 @@ export function createRendering({ stationName: station.name, point, capacity: parts.model.toNonNegativeInteger(station.capacity), + publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), bikesAvailable: null, docksAvailable: null, isInstalled: station.isInstalled, @@ -245,7 +246,8 @@ export function createRendering({ if (!record) continue; const status = statusMap.get(record.stationId) || null; - const capacity = resolveCapacity(record.capacity, status); + // Resolve from the published capacity, not last poll's derived value. + const capacity = resolveCapacity(record.publishedCapacity, status); record.capacity = capacity; record.bikesAvailable = parts.model.toNonNegativeInteger( From f73cd5935b84807a2fa0fe5efe8df0e3bf784eb5 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:13:13 +0530 Subject: [PATCH 16/70] fix(missions): run the full T-10 replay countdown after the tile hold Replay Ascent schedules liftoff 15 s out: a 5 s tile-preparation hold, then a 10 s countdown. The hold ended 5 s before liftoff instead of 10, so the replay sat on the pad for 10 s and the countdown began at T-5. The hold now ends at T-10, and the countdown runs T-10 to T-1. --- src/data/rocketLaunches.test.mjs | 12 ++++++++++++ src/layers/launches/replay.js | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index b68a11db3..15b37420d 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -448,6 +448,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp assert.ok(liftoff.phaseProgress > 0); }); +test('runs the five-second tile hold before a full T-10 countdown', () => { + const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; + // REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead. + const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5); + assert.equal(hold.preCountdownActive, true); + assert.equal(hold.countdownActive, false); + const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5); + assert.equal(countdown.preCountdownActive, false); + assert.equal(countdown.countdownActive, true); + assert.equal(countdown.countdownSeconds, 10); +}); + test('hands replay directly from ascent into orbit', () => { const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 5354996bd..4317de0ab 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) { 0, Number(preCountdownDurationSec) || 0, ); + // The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5. const preCountdownActive = - preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration; + preCountdownDuration > 0 && + realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC; const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; const countdownSeconds = countdownActive ? Math.ceil(-realSecondsSinceStart) From 182e6ceddd8a9731770cc0cbe9376ea2d671b8c5 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:13:15 +0530 Subject: [PATCH 17/70] fix(tools): list a split submarine cable once in find_submarine_cables TeleGeography stores 17 cables (2Africa, Echo, Hawaiki...) as several features under one id, and the tool made a row per feature, so "Echo" read "3 submarine cables" and area counts ran high. Rows are now unique by cable id, and a cable still matches when any of its parts does. --- docs/CURRENT-STATE.md | 3 ++ src/tools/queries/cables.js | 6 ++++ src/tools/queries/cables.test.mjs | 53 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..34a8ee692 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -179,6 +179,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). Aircraft altitude-band queries require a reported barometric or geometric altitude for each match, so a grounded contact that reports none is left out of a band too. Queries without an altitude band retain unknown-altitude contacts. +`find_submarine_cables` lists and counts each cable once: TeleGeography splits +a few cables (2Africa, Echo) into several features under one id, and such a +cable matches when any of its parts crosses the area. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index 8d8c0cecf..a4f020150 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -57,6 +57,7 @@ export const findSubmarineCables = defineTool({ Number.isFinite(point.lat) && (!area || areaContains(area, point)), ); + const seen = new Set(); const rows = (cables?.features || []) .filter((feature) => { const name = String(feature.properties?.name || ''); @@ -71,6 +72,11 @@ export const findSubmarineCables = defineTool({ id: feature.properties.id ?? null, name: feature.properties.name, })) + // TeleGeography splits some cables into several features under one id. + .filter((row) => { + const key = row.id ?? row.name; + return !seen.has(key) && seen.add(key); + }) .sort((a, b) => a.name.localeCompare(b.name)); const where = area ? ` in ${area.label}` : ''; const what = wanted ? ` matching "${args.name}"` : ''; diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs index f74457087..37c18fa61 100644 --- a/src/tools/queries/cables.test.mjs +++ b/src/tools/queries/cables.test.mjs @@ -89,6 +89,59 @@ test('cables are found by area and name, with landing points and attribution', a ); }); +test('a cable split into several features is listed once', async () => { + // TeleGeography splits a few cables (2Africa, Echo, Hawaiki) into several + // features under one id. + const cable = (id, name, coordinates) => ({ + properties: { id, name }, + geometry: { type: 'MultiLineString', coordinates: [coordinates] }, + }); + const split = { + async fetch() { + return { + cables: { + features: [ + cable('2africa', '2Africa', [ + [60, 18], + [57, 22], + ]), + cable('equiano', 'Equiano', [ + [-10, 5], + [3, 6], + ]), + cable('2africa', '2Africa', [ + [-21, 16], + [-17, 14], + ]), + ], + }, + landingPoints: { features: [] }, + }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cables: split }, + }); + const named = await catalog.call('find_submarine_cables', { + name: '2africa', + }); + assert.equal(named.summary, '1 submarine cable matching "2africa".'); + assert.deepEqual(named.data.rows, [{ id: '2africa', name: '2Africa' }]); + const both = await catalog.call('find_submarine_cables', { + area: { bbox: [-30, 0, 70, 30] }, + }); + assert.deepEqual( + both.data.rows.map((row) => row.id), + ['2africa', 'equiano'], + ); + // Only the second part crosses this box; the cable is still found. + const west = await catalog.call('find_submarine_cables', { + area: { bbox: [-25, 10, -10, 20] }, + }); + assert.deepEqual(west.data.rows, [{ id: '2africa', name: '2Africa' }]); +}); + test('the local fetch serves bundled data files and nothing outside them', async () => { const fetchImpl = createApiFetch({ apiBase: 'http://127.0.0.1:1', From 6364817b5b83adf099b370c951cc38170eb18cba Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:16:45 +0530 Subject: [PATCH 18/70] fix(directions): show a 9.96 km route as 10 km, not 10.0 km routeDistanceText picked one decimal while the raw kilometres were under 10, then rounded them for display, so 9.95 km or more read "10.0 km". It now picks the unit from the rounded tenth, as it already does at the metre/kilometre boundary. --- docs/CURRENT-STATE.md | 4 +++- src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..ce635160b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4172,7 +4172,9 @@ animate). Disable clears all state. At the metre/kilometre boundary, route distance labels use the rounded ten-metre value to select the unit, switching to kilometres at 995 metres; -a 996-metre leg displays as `1.0 km` rather than `1000 m`. +a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres +start where the label would round to 10.0 km, so a 9.96 km route displays as +`10 km` rather than `10.0 km`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index bf6e2878e..a51796edd 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', ( assert.equal(routeDistanceText(994), '990\u00a0m'); assert.equal(routeDistanceText(996), '1.0\u00a0km'); assert.equal(routeDistanceText(1500), '1.5\u00a0km'); + assert.equal(routeDistanceText(9940), '9.9\u00a0km'); + assert.equal(routeDistanceText(9960), '10\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); assert.equal(routeDurationText(59.4), '59\u00a0s'); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b8cceff02..9d0516c28 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -115,7 +115,8 @@ export function routeDistanceText(meters) { const tens = Math.round(meters / 10) * 10; if (tens < 1000) return formatQuantity(tens, 'm'); const km = meters / 1000; - return km < 10 + // Pick the unit from the tenth it prints: 9.96 km read "10.0 km". + return Math.round(meters / 100) < 100 ? formatQuantity(km, 'km', { minimumFractionDigits: 1, maximumFractionDigits: 1, From a2c813280e66f9aec36ea1fa64a8cbc33658a4ea Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:16:54 +0530 Subject: [PATCH 19/70] fix(missions): keep the replay countdown real-time when the speed changes Moving the replay-speed slider during the pre-launch hold or countdown rescaled the time left to liftoff, so 0.25x turned T-8 into T-32 and 4x into T-2. The speed change now skips a replay that has not lifted off; mission time already flown is still preserved as before. --- src/layers/launches/replay.js | 3 +- src/layers/launches/replay.test.mjs | 47 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/layers/launches/replay.test.mjs diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 5354996bd..affcba339 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -281,7 +281,8 @@ export function createReplay({ state: layerState, services, parts, source }) { ? layerState._replayPausedAtMs : Date.now(); for (const [launchId, startedAt] of layerState._animationStarts) { - if (!Number.isFinite(startedAt)) continue; + // A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x. + if (!Number.isFinite(startedAt) || startedAt > now) continue; const elapsedMissionMs = (now - startedAt) * previousSpeed; layerState._animationStarts.set( launchId, diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs new file mode 100644 index 000000000..4ed8e2ede --- /dev/null +++ b/src/layers/launches/replay.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createReplay } from './replay.js'; +import { createPaths } from './paths.js'; + +const noop = () => {}; + +function replayRig() { + const state = createState({ + services: { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + }, + }); + const parts = {}; + parts.paths = createPaths({ state, parts }); + return { state, replay: createReplay({ state, parts }) }; +} + +test('changing replay speed during the countdown keeps its real-time liftoff', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const { state, replay } = replayRig(); + const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; + state._animationStarts.set('countdown', 1_008_000); // T-8 + state._animationStarts.set('flying', 990_000); // 10 s into the mission + + replay.setReplaySpeed(0.25); + + const startedAt = state._animationStarts.get('countdown'); + assert.equal(startedAt, 1_008_000); + const countdown = replay.replayState( + launch, + startedAt, + 12, + 28, + 5400, + state._replaySpeed, + Date.now(), + ); + assert.equal(countdown.countdownSeconds, 8); + // Mission time already flown is still preserved across the change. + assert.equal(state._animationStarts.get('flying'), 960_000); +}); From c7377362a5cfb8422e1ff5fa7cad403a445b661b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:18:30 +0530 Subject: [PATCH 20/70] fix(local-adsb): show a decoder feed's GNSS-sourced vertical rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dump1090-fa and readsb file an aircraft's vertical rate under geom_rate instead of baro_rate when its source is GNSS. The decoder-feed adapter read only baro_rate, so those aircraft showed "V/S —" on the Local ADS-B card. It now falls back to geom_rate; a sent baro_rate still wins. --- docs/CURRENT-STATE.md | 4 +++- src/sources/adsbRecords.js | 4 +++- src/sources/adsbRecords.test.mjs | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..1d2825fcd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6134,7 +6134,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js` `lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is `webusb` (browser SDR) or `feed` (decoder feed). `normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ -skyaware978 `aircraft.json` document into the same records. Voice +skyaware978 `aircraft.json` document into the same records; a row without +`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate +is sent. Voice `set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", "my antenna"). diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 814da60b2..465321902 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -247,7 +247,9 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { altitudeFt: altitude, groundSpeedKt: finiteOrNull(entry.gs), trackDeg: normalizeTrack(entry.track), - verticalRateFpm: finiteOrNull(entry.baro_rate), + // A GNSS-sourced rate is sent as geom_rate instead of baro_rate. + verticalRateFpm: + finiteOrNull(entry.baro_rate) ?? finiteOrNull(entry.geom_rate), lastPositionAt: hasPosition ? receivedAt - Math.max(0, seenPosS) * 1000 : null, diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index 12070d47b..aa14133b6 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -169,6 +169,20 @@ test('a feed document names its band on every record', () => { assert.ok(uat.every((record) => record.source === 'feed')); }); +test('a decoder feed reads a GNSS-sourced vertical rate when no baro rate is sent', () => { + // dump1090-fa and readsb file a velocity message's rate under geom_rate + // instead of baro_rate when its source bit says GNSS. + const rate = (fields) => + normalizeDump1090Aircraft( + { aircraft: [{ hex: 'abc123', seen: 1, ...fields }] }, + 10_000, + )[0].verticalRateFpm; + assert.equal(rate({ geom_rate: -832 }), -832); + assert.equal(rate({ baro_rate: -768, geom_rate: -832 }), -768); + assert.equal(rate({ baro_rate: 0, geom_rate: -832 }), 0); + assert.equal(rate({}), null); +}); + function heard(overrides = {}) { return { icao: 'abc123', From e4a964375a0fa0f50a1cf58166f5a5a27fea3eaf Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:47 +0530 Subject: [PATCH 21/70] fix(annotations): label a 996 m line or route as 1.0 km, not 1000 m Placed route, arrow and drawn-line labels picked metres before rounding to ten metres, so anything from 995 m to 1 km read "1000 m", even where the draw hint for the same line read "1.0 km". The label now rounds first, then picks the unit, and does the same at the 10 km cut, where 9,960 m read "10.0 km". --- docs/CURRENT-STATE.md | 2 +- src/annotations/annotationEngine.js | 7 +++++-- src/annotations/annotationEngine.test.mjs | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..1b08eb7d9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5347,7 +5347,7 @@ request viewbox and continues searching without that geographic preference. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. -- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index b0622579d..c867782c7 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1573,8 +1573,11 @@ function greatCircleM(a, b) { function formatDistance(m) { if (!Number.isFinite(m)) return null; - if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; - return `${Math.round(m / 10) * 10} m`; + // Round before each unit cut: 996 m was printed as "1000 m" and + // 9,960 m as "10.0 km". + const tens = Math.round(m / 10) * 10; + if (tens < 1000) return `${tens} m`; + return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`; } function composeRouteLabel(baseLabel, distM, durS, mode, fallback) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 5f39a077a..4f5804290 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t assert.deepEqual(received.map(options => options.allowDistant), [false, true]); engine.destroy(); }); + +test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => { + installAnimationFrameStubs(t); + const labels = []; + const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + for (const metres of [994, 996, 9940, 9960]) { + await engine.annotate([ + { type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] }, + ]); + } + assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']); + engine.destroy(); +}); From c6f437ed527210527c01db85800bbe767f66f375 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:16:02 +0530 Subject: [PATCH 22/70] fix(traffic): stop a few matched roads reading as "no matches" The live flow status derived its wording from the rounded coverage percent, so 1 matched dot among 300 rounded to 0% and read "SIMULATED ... (no matches)", and 1 simulated dot among 300 rounded to 100% and dropped "Unmatched: simulated". Coverage now stays within 1-99% whenever both matched and simulated dots are shown. --- docs/CURRENT-STATE.md | 8 +++++--- src/layers/traffic/controls.js | 15 +++++++++++++-- src/layers/traffic/source.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..f6e61360f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5810,9 +5810,11 @@ easier to meet (detection is now on more often), but does not create it. OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow refreshes clear old matches and report simulation. `flowCoveragePct` is the percentage of shown dots on roads with flow, including all unmatched dots in - the denominator and excluding dots hidden by closures. The OSM keyed status - reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch - coverage follows a 5×5 ellipsoid sample of the central screen square. + the denominator and excluding dots hidden by closures. It reads 1–99 whenever + both matched and simulated dots are shown, so rounding never turns a few + matches into `(no matches)` or a few simulated dots into full coverage. The + OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. + Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square. Horizon rays are capped at 10 km from nadir (reduced if necessary to fit 16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14 tiles provide near detail, with z12 roads beyond them. Cached tiles serve diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..1a8eea4d6 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) { * `error` carries this instant's health, so a live-configured layer whose * flow feed went down reads DEGRADED with the reason instead of a stale * LIVE coverage number. `flowCoveragePct` is shown dots on matched roads / - * all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests + * all shown dots (0–100 int, kept within 1–99 while both kinds are shown), + * excluding closures; `tilesFetched` counts flow-tile requests * issued to the proxy this session (decode-cache hits excluded). * @returns {{count:number, lastUpdate:number|null, loading:boolean, * mode:'live'|'sim', error:string|null, flowCoveragePct:number, @@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) { Boolean(layerState._heightPending); const { free, slow, jam, sim } = layerState._bucketCounts; const matched = free + slow + jam; + // The label reads 0 as "no matches" and 100 as nothing simulated, so + // rounding must not reach either while both kinds of dot are shown. const flowCoveragePct = - matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0; + matched + sim > 0 + ? Math.min( + sim > 0 ? 99 : 100, + Math.max( + matched > 0 ? 1 : 0, + Math.round((100 * matched) / (matched + sim)), + ), + ) + : 0; const feed = parts.model.trafficFeedPresentation({ liveMode: layerState._liveMode, fetching: loading, diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 927dc416e..51b24b7df 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -516,6 +516,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and assert.equal(controls.getStats().flowCoveragePct, 80); }); +test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => { + const { state, controls } = await matchingContext(async () => []); + state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 1); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 }; + assert.equal(controls.getStats().flowCoveragePct, 99); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 }; + assert.equal(controls.getStats().flowCoveragePct, 100); + state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 0); +}); + test('refresh failure removes cached matches and reports simulated flow', async () => { const { flow, state, model, controls } = await matchingContext(async () => { throw new Error('HTTP 429'); From 22b4169a8979a13c90d78151faea1a2c54cd58db Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:09 +0530 Subject: [PATCH 23/70] docs(changelog): add #1442 fragment --- changelog.d/1442-traffic-coverage-edges.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1442-traffic-coverage-edges.md diff --git a/changelog.d/1442-traffic-coverage-edges.md b/changelog.d/1442-traffic-coverage-edges.md new file mode 100644 index 000000000..63890cc35 --- /dev/null +++ b/changelog.d/1442-traffic-coverage-edges.md @@ -0,0 +1,4 @@ +- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched + dots among hundreds rounded to 0% and read "(no matches)", and a few + simulated dots rounded to "100% cov"; coverage now stays within 1–99% while + both are shown (Aditya, @Rohilalala, #1442). From df71c37d27cf7027b8f72e33905a3c29da4fb81f Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:17 +0530 Subject: [PATCH 24/70] docs(changelog): add #1443 fragment --- changelog.d/1443-replay-countdown-ten.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1443-replay-countdown-ten.md diff --git a/changelog.d/1443-replay-countdown-ten.md b/changelog.d/1443-replay-countdown-ten.md new file mode 100644 index 000000000..4780f26bf --- /dev/null +++ b/changelog.d/1443-replay-countdown-ten.md @@ -0,0 +1,3 @@ +- Run Space Missions' Replay Ascent countdown from T−10 after the five-second + tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya, + @Rohilalala, #1443). From 5d2f6a3ce5bffce34466de1d70a5f06a89366f0c Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:25 +0530 Subject: [PATCH 25/70] docs(changelog): add #1444 fragment --- changelog.d/1444-replay-speed-countdown.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1444-replay-speed-countdown.md diff --git a/changelog.d/1444-replay-speed-countdown.md b/changelog.d/1444-replay-speed-countdown.md new file mode 100644 index 000000000..fb35d008b --- /dev/null +++ b/changelog.d/1444-replay-speed-countdown.md @@ -0,0 +1,3 @@ +- Keep Space Missions' replay countdown in real time when the replay speed + changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya, + @Rohilalala, #1444). From 81cf79157cff29960d4e5c9174507255eed2c74b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:34 +0530 Subject: [PATCH 26/70] docs(changelog): add #1445 fragment --- changelog.d/1445-annotation-label-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1445-annotation-label-units.md diff --git a/changelog.d/1445-annotation-label-units.md b/changelog.d/1445-annotation-label-units.md new file mode 100644 index 000000000..494c663db --- /dev/null +++ b/changelog.d/1445-annotation-label-units.md @@ -0,0 +1,3 @@ +- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of + 9,950 m or more as 10 km. The placed label picked the unit before rounding, + so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445). From f892eaa1742619275a445e93fd52b728ea231113 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:49 +0530 Subject: [PATCH 27/70] docs(changelog): add #1447 fragment --- changelog.d/1447-bikeshare-derived-capacity.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1447-bikeshare-derived-capacity.md diff --git a/changelog.d/1447-bikeshare-derived-capacity.md b/changelog.d/1447-bikeshare-derived-capacity.md new file mode 100644 index 000000000..c241b58f7 --- /dev/null +++ b/changelog.d/1447-bikeshare-derived-capacity.md @@ -0,0 +1,3 @@ +- Bike Share stations whose feed publishes no capacity now take it from each + status poll's bikes and docks instead of keeping the first poll's value or + the placeholder 15 (Aditya, @Rohilalala, #1447). From 58e9f5ab2e5f5a7a1fd0c7174722e3391298b4aa Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:57 +0530 Subject: [PATCH 28/70] docs(changelog): add #1448 fragment --- changelog.d/1448-local-adsb-geom-rate.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1448-local-adsb-geom-rate.md diff --git a/changelog.d/1448-local-adsb-geom-rate.md b/changelog.d/1448-local-adsb-geom-rate.md new file mode 100644 index 000000000..276e58f3e --- /dev/null +++ b/changelog.d/1448-local-adsb-geom-rate.md @@ -0,0 +1,3 @@ +- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate + (`geom_rate`) when no barometric rate is sent, so the card shows V/S + instead of a dash (Aditya, @Rohilalala, #1448). From 0e0b82f511f79de85f81c535904fc6b351a1e4ca Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:25:05 +0530 Subject: [PATCH 29/70] docs(changelog): add #1449 fragment --- changelog.d/1449-cable-tool-duplicates.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1449-cable-tool-duplicates.md diff --git a/changelog.d/1449-cable-tool-duplicates.md b/changelog.d/1449-cable-tool-duplicates.md new file mode 100644 index 000000000..e3cc8df96 --- /dev/null +++ b/changelog.d/1449-cable-tool-duplicates.md @@ -0,0 +1,3 @@ +- List each submarine cable once in find_submarine_cables. TeleGeography + splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a + search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449). From 50e06181c6f82780d57a1653335ebd73e46ee657 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:25:12 +0530 Subject: [PATCH 30/70] docs(changelog): add #1450 fragment --- changelog.d/1450-directions-ten-km.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1450-directions-ten-km.md diff --git a/changelog.d/1450-directions-ten-km.md b/changelog.d/1450-directions-ten-km.md new file mode 100644 index 000000000..1b348493e --- /dev/null +++ b/changelog.d/1450-directions-ten-km.md @@ -0,0 +1,3 @@ +- Show a route of 9.95 km or more as 10 km. The Directions row and step + cards kept one decimal until the raw distance reached 10 km, so 9.96 km + read "10.0 km" (Aditya, @Rohilalala, #1450). From 46b3864d616d6e73e1112ba70177c2218d61de89 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:15:42 +0530 Subject: [PATCH 31/70] fix(satellites): stop matching a mission to a namesake from another year The mission orbit lookup reads each loaded satellite's launch year from its international designator, but satellite.js never parses that field, so the year check never ran and a new payload could take an older namesake's orbit and position. The loaded catalog now keeps the designator from the TLE line. --- src/layers/satellites/catalog.js | 2 ++ src/layers/satellites/ingestion.js | 2 ++ src/layers/satellites/ingestion.test.mjs | 20 ++++++++++++++++++++ src/layers/satellites/stale.test.mjs | 14 ++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 097d9e3be..008b2a19d 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -114,6 +114,8 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entry = entries[i]; const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; + // satellite.js omits the designator the launch-year match reads. + satrec.intldesg = entry.line1.slice(9, 17).trim(); const noradId = noradIdFromSatnum(satrec.satnum); if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority const pos = parts.orbits.propagatePosition(satrec, now); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 01653611c..168a5903b 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -119,6 +119,8 @@ export function createIngestion({ for (const entry of allEntries) { const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; + // satellite.js omits the designator the launch-year match reads. + satrec.intldesg = entry.line1.slice(9, 17).trim(); const noradId = noradIdFromSatnum(satrec.satnum); if (seen.has(noradId)) continue; diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 313d999ee..7605b318c 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseTleText } from '../../sources/tle.js'; import { createIngestion } from './ingestion.js'; +import { createOrbits } from './orbits.js'; // The ISS set (1 January 2024) with its catalog field swapped per satellite. const ISS_LINE1 = @@ -78,3 +79,22 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]); assert.equal(state._count, 3); }); + +test('a mission payload does not match a loaded satellite from another launch year', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + // SXM-11, international designator 2026-148A. + const { state, update } = ingestionFor( + [ + 'SXM-11', + '1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990', + '2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440', + ].join('\n'), + ); + await update(); + const { getSatelliteOrbitTrack } = createOrbits({ state }); + const track = (launchTime) => + getSatelliteOrbitTrack('Sirius SXM-11', { launchTime }); + assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728); + assert.equal(track('2030-06-29T02:25:00Z'), null); +}); diff --git a/src/layers/satellites/stale.test.mjs b/src/layers/satellites/stale.test.mjs index 89b4d2dbf..9cca23610 100644 --- a/src/layers/satellites/stale.test.mjs +++ b/src/layers/satellites/stale.test.mjs @@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a ); }); +test('dense extras keep their launch year for mission orbit matching', async (t) => { + const f = await fixture(t); + await f.update(); + f.state._params.catalog = 'dense'; + assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready'); + // Every fixture set carries the 1998-067A designator. + const track = (launchTime) => + f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', { + launchTime, + }); + assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551); + assert.equal(track('2026-06-29T02:25:00Z'), null); +}); + test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => { const f = await fixture(t); await f.update(); From a22f67ab6571afc14be17e30a10f1e1c6ebb116b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:53:47 +0530 Subject: [PATCH 32/70] docs(changelog): add #1453 fragment --- changelog.d/1453-satellite-launch-year.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1453-satellite-launch-year.md diff --git a/changelog.d/1453-satellite-launch-year.md b/changelog.d/1453-satellite-launch-year.md new file mode 100644 index 000000000..a44745e1f --- /dev/null +++ b/changelog.d/1453-satellite-launch-year.md @@ -0,0 +1,3 @@ +- Space Missions no longer shows an older satellite with a similar name as a + new payload's orbit: the launch-year check on loaded satellites now runs + (Aditya, @Rohilalala, #1453). From ac0e124a67ea8cc45435fc0d6b51136656f19b61 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:11:42 +0530 Subject: [PATCH 33/70] =?UTF-8?q?fix(flights):=20read=20a=20359.6=C2=B0=20?= =?UTF-8?q?track=20as=200=C2=B0=20in=20the=20voice=20context,=20not=20360?= =?UTF-8?q?=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voice context records for a selected flight or military contact rounded the track without wrapping, so a contact on 359.5° or more was described to the assistant as heading "360°". Both now wrap after rounding: 359.6° reads "0°", 359.4° still reads "359°". --- docs/CURRENT-STATE.md | 2 +- src/layers/flights/ownership.test.mjs | 22 ++++++++++++++++++++++ src/layers/flights/tracking.js | 3 ++- src/layers/military/ownership.test.mjs | 25 +++++++++++++++++++++++++ src/layers/military/tracking.js | 3 ++- 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..7bd48f5e7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5164,7 +5164,7 @@ and unreachable upstream (502/504) separately from road geometry. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). - **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`. - **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight. - - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. + - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`. - **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload. - **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`). - **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt. diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 813df5641..16c1a9cc6 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createCivilFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createEnrichment } from './enrichment.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy await new Promise((done) => setImmediate(done)); assert.equal(flightState._enrichActive, 0); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: services(), + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, altitudeM: 9000 }), + _contactLabel: () => 'AAL1', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('abc123').properties.heading, + '0°', + ); +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dde8f4f95..dd61e6948 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -125,8 +125,9 @@ export function createTracking({ speed: Number.isFinite(described.velocityMps) ? `${Math.round(described.velocityMps * 1.944)} kt` : '', + // 359.5° and above rounds to north; modulo keeps it from reading 360°. heading: Number.isFinite(described.track) - ? `${Math.round(described.track)}°` + ? `${Math.round(described.track) % 360}°` : '', route: route || '', icao24, diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 41081c137..49aebee76 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, ); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const stubs = services(); + stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type }; + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: stubs, + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }), + _toCleanText: (value) => value || '', + _formatAltitude: () => '', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('ae1234').properties.heading, + '0°', + ); +}); diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8c9723176..b43a4d88b 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -119,8 +119,9 @@ export function createTracking({ speed: Number.isFinite(described.velocityMps) ? `${Math.round(described.velocityMps * 1.944)} kt` : '', + // 359.5° and above rounds to north; modulo keeps it from reading 360°. heading: Number.isFinite(described.track) - ? `${Math.round(described.track)}°` + ? `${Math.round(described.track) % 360}°` : '', icao24, // Honesty cue: the contact is coasting on dead reckoning, so the From bf5cbe74233b9e986d01099302536442bb8d567d Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:16:40 +0530 Subject: [PATCH 34/70] docs(changelog): add #1454 fragment --- changelog.d/1454-voice-context-heading-north.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1454-voice-context-heading-north.md diff --git a/changelog.d/1454-voice-context-heading-north.md b/changelog.d/1454-voice-context-heading-north.md new file mode 100644 index 000000000..ee9f3a63a --- /dev/null +++ b/changelog.d/1454-voice-context-heading-north.md @@ -0,0 +1,2 @@ +- Describe a selected flight or military contact on 359.5° or more to the + voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454). From d8358048adafa95930c1be2db9b624627f51e332 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 28 Oct 2026 16:07:48 +0100 Subject: [PATCH 35/70] fix(dock): show only icons on the narrow dock wings so labels stop covering the voice control At 900px and narrower the Location and Visual Presets wings shrink to --dock-side-compact (4.25rem), open or closed, but their text labels kept their full width. Between 721px and 900px, where the desktop dock is still shown, LOCATION and VISUAL PRESETS spilled over the idle voice control, and the LOCATION label took taps on the left half of the mic button. The label clip that existed was scoped to 620px, which the phone shell (720px) always covers, so it never applied to a visible dock. In that band the labels are now font-size 0 and lose their chevrons, so each wing shows only its icon. The label text is unchanged, so the toggles' "Expand/Collapse ..." aria-label and title, which panelChrome builds from it, keep the full name. The Cyber HUD layout sets a wider --dock-side-compact that fits its labels, so it keeps them. The unreachable 620px clip is removed. Found in GEV launch-readiness QA (HAL-728). --- docs/CURRENT-STATE.md | 14 +++++++++++ src/ui/styles/command-dock-trays.css | 35 ++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e7f333a1..cfa9be32f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2665,6 +2665,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding > a new tray rule means checking it against the pinned variants, not just > against the base rule. +> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at +> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings +> to 4.25rem, open or closed (an open tray pops out above the dock), which is +> too narrow for their text labels. The labels used to spill over the voice +> control, and the LOCATION label captured taps on the left half of the mic +> button (HAL-728). `src/ui/styles/command-dock-trays.css` now sets the +> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so +> each wing shows only its icon. The text node is unchanged, so +> `panelChrome.js` still builds the toggles' "Expand/Collapse …" `aria-label` +> and `title` from it; the browser tooltip is the only visible name on hover. +> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to +> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone: +> the phone shell takes over at 720px, so it never applied to a visible +> dock. > - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed > readout now follows a free-text geocode search as well as preset pills > (`src/locationStatus.js` owns the copy for both), and every other camera diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index 547037de7..5575b588a 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -502,6 +502,34 @@ body:not(.ui-clean-view):not(.recording-mode):has( #command-dock .gev-voice-error-tray { width: min(24rem, calc(100vw - 1.5rem)); } + + /* The wings shrink to --dock-side-compact at this width, open or closed (an + open tray pops out above the dock), which is too narrow for the text + labels; they would spill over the voice control and take its taps. The + wings show only their icon; the toggles keep their aria-label. Cyber sets + a wider --dock-side-compact that fits its labels, so it keeps them. */ + :root:not([data-ui-theme='cyber']) + #command-dock + :is(#location-bar, #control-panel) + .panel-title, + :root:not([data-ui-theme='cyber']) + #command-dock + :is(#location-bar, #control-panel) + .location-toolbar-label { + font-size: 0; + letter-spacing: 0; + } + + :root:not([data-ui-theme='cyber']) + #command-dock + :is(#location-bar, #control-panel) + .panel-title::after, + :root:not([data-ui-theme='cyber']) + #command-dock + :is(#location-bar, #control-panel) + .location-toolbar-label::after { + content: none; + } } @media (max-width: 620px) { @@ -510,13 +538,6 @@ body:not(.ui-clean-view):not(.recording-mode):has( --dock-voice-width: 8.75rem; } - #command-dock .panel-title, - #command-dock .location-toolbar-label { - max-width: 2.3rem; - overflow: hidden; - text-overflow: ellipsis; - } - #left-panel-stack { bottom: calc(50vh + 8px); } From ce10a46b7f1780fcdeadfaf5f47396ce79065393 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 28 Oct 2026 20:22:01 +0100 Subject: [PATCH 36/70] docs(changelog): add the dock icon-only wings entry --- changelog.d/1457-dock-icon-only-wings.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1457-dock-icon-only-wings.md diff --git a/changelog.d/1457-dock-icon-only-wings.md b/changelog.d/1457-dock-icon-only-wings.md new file mode 100644 index 000000000..21825669b --- /dev/null +++ b/changelog.d/1457-dock-icon-only-wings.md @@ -0,0 +1,5 @@ +- Between 721px and 900px wide (tablet portrait, phone landscape), the + Location and Visual Presets dock wings show only their icons, so their + labels no longer spill over the voice control or take taps meant for the + mic button. The full name stays in the tooltip and accessible name, and the + Cyber layout keeps its labels (ZloyS, #1457). From 2a335a8f3560ab16bc4fe5cd4956939def32ad46 Mon Sep 17 00:00:00 2001 From: Luis Ortiz <2839770+anobjectn@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:34:04 -0400 Subject: [PATCH 37/70] fix(deps): pin Node 24.22.1 for the October 21, 2026 security release Node.js 24.14.0 carries CVE-2026-111497 ("Node2Shell"), an out-of-bounds write in the bundled Brotli decoder that can lead to remote code execution, plus several high-severity DNS, TLS and URL-parsing fixes. 24.22.1 is the first patched 24.x release. - Dockerfile: node:24.22.1-slim, digest re-pinned to the multi-arch index - CI: 24.22.1 in the verify matrix and the other two jobs; the allocation-gate requirement now keys on 24.22.1 - Docs: contributor setup and the dependency baseline name 24.22.1 The engines floor and the setup doctor are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 8 ++++---- CONTRIBUTING.md | 8 +++++--- Dockerfile | 2 +- README.md | 5 +++-- docs/CURRENT-STATE.md | 4 +++- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3563685..766f0cc51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: strategy: fail-fast: false matrix: - node: [24.14.0, 26.x] + node: [24.22.1, 26.x] env: PUPPETEER_SKIP_DOWNLOAD: '1' - GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }} + GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.22.1' && '1' || '0' }} steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -76,7 +76,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 24.14.0 + node-version: 24.22.1 cache: npm - name: Install locked dependencies and headless Chrome @@ -131,7 +131,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 24.14.0 + node-version: 24.22.1 cache: npm - name: Run the Pinokio install path diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7fe8ab76..591cdd85d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,15 @@ Thanks for being here. God's Eye View is an open foundation for live spatial int ## Getting set up -Use Node.js 24.14.x or 26.x (also enforced by `package.json`). +Use Node.js 24.14.x or 26.x (also enforced by `package.json`). Prefer 24.22.1 or +later: earlier releases lack the October 21, 2026 security fixes, including +CVE-2026-111497 (a Brotli decoder flaw that can lead to remote code execution). ```bash git clone https://github.com/bilawalsidhu/gods-eye-view.git cd gods-eye-view -nvm install 24.14.0 -nvm use 24.14.0 +nvm install 24.22.1 +nvm use 24.22.1 npm install npm run doctor ./scripts/dev-fresh.sh # or: npm run dev (keys are optional) diff --git a/Dockerfile b/Dockerfile index 433d170a8..e711ec1d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there # is nothing else to containerise. See compose.yaml for the usual entry point. # Same Node as CI (package.json engines, .github/workflows), pinned by digest. -FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8 +FROM node:24.22.1-slim@sha256:be8204d4bd327363357f532187b752ff62759c31bedf98e9e18fc08c9cbd605c # Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. ENV PUPPETEER_SKIP_DOWNLOAD=1 diff --git a/README.md b/README.md index 0a7509efa..ee9c7819a 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,9 @@ hardware; it does not establish a minimum GPU requirement. ### Path 2 — Terminal / coding agent -Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about -Node 25, which is end-of-life. +Use **Node.js 24.x (24.14.0 or later) or 26.x**; 24.22.1 or later (26.12.1 or +later on 26) includes the October 21, 2026 security fixes, among them +CVE-2026-111497. The setup doctor warns about Node 25, which is end-of-life. ```bash git clone https://github.com/bilawalsidhu/gods-eye-view.git diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e7f333a1..098bd3e83 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6024,7 +6024,9 @@ The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28, source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain -24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree. +24.14.x and 26.x; the Docker image and CI pin 24.22.1, the first 24.x release with +the October 21, 2026 Node.js security fixes (including CVE-2026-111497). Use +`npm ci` to reproduce the checked-in dependency tree. ### Traffic city navigation From 17d0877a4cedf1b44b9ce8f0927654ef5436390d Mon Sep 17 00:00:00 2001 From: Luis Ortiz <2839770+anobjectn@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:35:06 -0400 Subject: [PATCH 38/70] docs(changelog): add the #1458 fragment Co-Authored-By: Claude Sonnet 5.5 --- changelog.d/1458-node-24-22-1-security-release.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1458-node-24-22-1-security-release.md diff --git a/changelog.d/1458-node-24-22-1-security-release.md b/changelog.d/1458-node-24-22-1-security-release.md new file mode 100644 index 000000000..a440892a8 --- /dev/null +++ b/changelog.d/1458-node-24-22-1-security-release.md @@ -0,0 +1,3 @@ +- The Docker image and CI now pin Node 24.22.1, the first 24.x release with + the October 21, 2026 security fixes, including CVE-2026-111497 (a Brotli + decoder flaw that can lead to remote code execution) (anobjectn, #1458). From 45ae1095da5eba3e9eae3b668645ee33d42bf607 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:11:55 -0700 Subject: [PATCH 39/70] fix(voice): leave aliases a layer id already spells out of the alias hint The set_layer_visibility layerId hint listed aliases such as "dams" for local-dams and "weather radar" for weather-radar, which the id in the enum already says. Leaving those out, and a layer with no other alias, cuts the Realtime prompt from 52,000 to 51,695 characters. Aliases the id does not spell (aqhi, hurricanes, clouds) stay, and the runner still accepts every alias. Refs #1363. Co-Authored-By: Claude Opus 5.5 --- src/firstRunExperience.test.mjs | 5 +++-- src/voice/actionSchemas.test.mjs | 3 ++- src/voice/layerManifest.js | 19 +++++++++++++++---- src/voice/layerManifest.test.mjs | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index d76a48458..9ba474dd3 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1055,10 +1055,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Panel adds timeout and named voice/provider targets; consolidated guidance // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. - assert.equal(block.length, 31206, 'serialized tool schema length drifted'); + // The layer alias hint leaves out aliases the layer id already spells. + assert.equal(block.length, 30901, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746', + '9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 97e5004b5..320704d3c 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -45,7 +45,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // S2 GhostMaps extends the generated layer enums. // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance // is consolidated without changing either feature's arguments. - '9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15', + // The layer alias hint leaves out aliases the layer id already spells. + '188e9eaaee1d5c21fc88493ba244569f4af9cfba61bb240c52ecd32b49b18caa', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 267399e58..faa7207d0 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -529,18 +529,29 @@ const SELF_EVIDENT_IDS = new Set([ 's2-ghostmaps', ]); +/** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */ +const spelled = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + /** * Compact model-facing alias hint: "alias/alias→id; …" for toggleable layers * whose id is not already the spoken word. At most two aliases per layer keep - * the tool JSON small; the runner accepts the full alias list. + * the tool JSON small; the runner accepts the full alias list. An alias the id + * already spells ("dams"→local-dams, "weather radar"→weather-radar) is left + * out, and so is a layer with no other alias. * @returns {string} One line for the layerId description. */ export function voiceLayerAliasHint() { return VOICE_LAYER_MANIFEST.filter( - (entry) => - entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), + (entry) => entry.toggle && !SELF_EVIDENT_IDS.has(entry.id), ) - .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) + .map((entry) => ({ + id: entry.id, + aliases: entry.aliases + .slice(0, 2) + .filter((alias) => !spelled(entry.id).includes(spelled(alias))), + })) + .filter((entry) => entry.aliases.length) + .map((entry) => `${entry.aliases.join('/')}→${entry.id}`) .join(';'); } diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..ab382c460 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -9,6 +9,7 @@ import { VOICE_TOGGLE_LAYER_IDS, VOICE_CONTEXT_LAYER_IDS, VOICE_QUERY_LAYER_IDS, + voiceLayerAliasHint, voiceLayerAliasMap, } from './layerManifest.js'; @@ -78,6 +79,23 @@ test('tool enums are generated from the manifest', () => { assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); }); +test('the alias hint leaves out names the layer id already spells', () => { + const hint = voiceLayerAliasHint(); + const entries = hint.split(';').map((entry) => entry.split('→')); + const flat = (text) => text.toLowerCase().replace(/[\s-]+/g, ''); + for (const [aliases, id] of entries) { + for (const alias of aliases.split('/')) { + assert.ok(!flat(id).includes(flat(alias)), `"${alias}" is already spelled by ${id}`); + } + } + const ids = entries.map(([, id]) => id); + assert.ok(!ids.includes('local-dams'), 'a layer left with no alias gets no entry'); + assert.ok(!ids.includes('weather-radar')); + assert.match(hint, /(^|;)aqhi→air-quality(;|$)/, 'an alias the id does not spell stays'); + assert.match(hint, /(^|;)hurricanes→weather-cyclones(;|$)/); + assert.match(hint, /(^|;)fires\/wildfires→local-firms(;|$)/); +}); + test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id); From f976ecf9e6247433428f707d1f25e23bae9410f9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:37:43 -0700 Subject: [PATCH 40/70] docs(changelog): add the voice alias hint entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1462-voice-alias-hint-budget.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1462-voice-alias-hint-budget.md diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md new file mode 100644 index 000000000..64cf56100 --- /dev/null +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -0,0 +1,5 @@ +- Leave layer aliases the layer id already spells, such as "dams" for + `local-dams`, out of the voice layer hint. The Realtime prompt drops from + 52,000 to 51,695 characters, leaving room under the 52,000 gate for new + voice-toggleable layers; every alias is still accepted (quanticsoul4772, + #1462). From 08dd88b2ce9a0380057ed3cf6e8c6ed7da2e972a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:12:46 +0000 Subject: [PATCH 41/70] Gather Directions, Draw and Import in a Utilities panel UTILITIES becomes its own panel in the left column, after Data Layers and CCTV and above Scenes. The layer panel renders the Directions row into the panel's list instead of Data Layers, and Draw and Import move there from DISPLAY with their ids unchanged. Radio, the other row under the old Utilities heading, stays in Data Layers under "Audio". The panel collapses and remembers its state like the other left panels; share links do not carry it. It hides in clean view, recording and Cockpit, takes the Cyber left-rail treatment, and sits collapsed in the phone's Layers sheet. Voice focuses the Directions row in its new list. The Directions, Draw, Import and form-field gates open UTILITIES to reach their controls. --- docs/CURRENT-STATE.md | 20 +++++- scripts/qa-directions.mjs | 22 +++--- scripts/qa-draw-tool.mjs | 18 +++-- scripts/qa-form-fields.mjs | 21 +++++- scripts/qa-geo-import.mjs | 8 ++- src/annotations/drawTool.test.mjs | 10 +-- src/i18n/locales/es.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-Hant.json | 2 + src/i18n/same-as-english.json | 1 + src/ui/cyberTheme.test.mjs | 21 ++++-- src/ui/geoFileImport.test.mjs | 4 +- src/ui/layerPanel.js | 48 ++++++++++--- src/ui/panelChrome.js | 1 + src/ui/styles/cockpit.css | 1 + src/ui/styles/controls.css | 1 + src/ui/styles/cyber.css | 12 ++-- src/ui/styles/layers.css | 98 ++++++++++++++++++++++++++ src/ui/styles/mobile-shell.css | 3 + src/ui/styles/recording.css | 1 + src/ui/styles/responsive.css | 2 + src/ui/styles/scenes.css | 18 ++++- src/ui/templates/display-controls.html | 35 --------- src/ui/templates/layer-panels.html | 50 +++++++++++++ src/voice/gevActions.js | 3 +- 26 files changed, 319 insertions(+), 87 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 146e157d8..0c3221945 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -131,6 +131,20 @@ provider registration rejects compositions that cannot fit rather than truncatin identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. +## Utilities panel — October 29, 2026 + +UTILITIES is its own panel in the left column, after Data Layers and CCTV and +above Scenes (`#utilities-panel` in `src/ui/templates/layer-panels.html`). It +holds the Directions layer row, which `LayerPanel` renders into +`#utilities-toggles` instead of the Data Layers list, and Draw and Import, +which moved there from DISPLAY with their ids unchanged. Radio, formerly the +other row under the Data Layers "Utilities" heading, stays in Data Layers under +"Audio". The panel collapses and remembers its state like the other left +panels; share links do not carry it. It hides in clean view, recording and +Cockpit, and on a phone it sits collapsed in the Layers sheet under Data +Layers. The Directions, Draw and Import QA gates open it to reach their +controls. + ## God's Eye View in conversations — October 2, 2026 Tool answers that can be shown in God's Eye View include a view: camera, layers, @@ -284,7 +298,7 @@ settings from active map/contact effects. Sonar settings are session-only and return to their defaults on reload. Sliders accept integer values matching the action's ranges. -Wind appears in the Weather group before Utilities. The surface-weather prototype +Wind appears in the Weather group before Audio. The surface-weather prototype uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid. It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning. Weather rows contain only the toggle and source/meta line. Configuration lives @@ -5396,7 +5410,7 @@ request viewbox and continues searching without that geographic preference. ### Manual Whiteboard Drawing (September 2026) -- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. +- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone. - Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is a DISPLAY-rail component template (`src/ui/templates/display-controls.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam. - Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. @@ -5436,7 +5450,7 @@ request viewbox and continues searching without that geographic preference. ### Import Your Own Files (October 2026) -- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. +- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. - **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy. - **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`. - **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged. diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index ca271026f..9fb8b9802 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -107,7 +107,7 @@ const layerState = () => const manager = gev.dataManager; const module = manager.layers.get('directions').module; const row = document.querySelector( - '#data-toggles [data-layer-id="directions"]', + '#utilities-toggles [data-layer-id="directions"]', ); const controls = module.getRowControls(); const viewer = gev.viewer; @@ -152,7 +152,7 @@ const layerState = () => const clickChip = async (chipId) => { const clicked = await page.evaluate((id) => { const row = document.querySelector( - '#data-toggles [data-layer-id="directions"]', + '#utilities-toggles [data-layer-id="directions"]', ); const button = row?.querySelector(`button[data-chip-id="${id}"]`); if (!button || button.disabled) return false; @@ -232,24 +232,26 @@ try { }); await sleep(3000); - // ── 1. Open the DATA LAYERS panel, then toggle the row on. ────────────── + // ── 1. Open the UTILITIES panel, then toggle the row on. ──────────────── const opened = await page.evaluate(() => { - const panel = document.getElementById('data-panel'); + const panel = document.getElementById('utilities-panel'); if (!panel) return false; if (panel.classList.contains('collapsed')) { panel - .querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]') + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) ?.click(); } return !document - .getElementById('data-panel') + .getElementById('utilities-panel') .classList.contains('collapsed'); }); await sleep(700); - check('the DATA LAYERS panel opens from its own header control', opened); + check('the UTILITIES panel opens from its own header control', opened); const toggled = await page.evaluate(() => { const row = document.querySelector( - '#data-toggles [data-layer-id="directions"]', + '#utilities-toggles [data-layer-id="directions"]', ); const button = row?.querySelector('.data-toggle-btn'); if (!button) return false; @@ -397,7 +399,7 @@ try { ); const focused = await page.evaluate(() => { const row = document.querySelector( - '#data-toggles [data-layer-id="directions"]', + '#utilities-toggles [data-layer-id="directions"]', ); const items = row.querySelectorAll('.data-row-list-item'); items[2]?.focus(); @@ -776,7 +778,7 @@ try { } } const row = document.querySelector( - '#data-toggles [data-layer-id="directions"]', + '#utilities-toggles [data-layer-id="directions"]', ); return { entities: left.length, diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index adb29f3af..e055f4a8f 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -196,12 +196,14 @@ try { ); check('visible tile content settles before drawing', settled); - // The DISPLAY rail starts collapsed on a first run: open it the way a user does. + // UTILITIES starts collapsed on a first run: open it the way a user does. await page.evaluate(() => { - const rail = document.getElementById('pp-toggles'); - if (rail?.classList.contains('collapsed')) + const panel = document.getElementById('utilities-panel'); + if (panel?.classList.contains('collapsed')) document - .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) ?.click(); }); await page.waitForFunction( @@ -480,10 +482,12 @@ try { // ── Clear wipes the board ─────────────────────────────────────────────── await page.evaluate(() => { - const rail = document.getElementById('pp-toggles'); - if (rail?.classList.contains('collapsed')) + const panel = document.getElementById('utilities-panel'); + if (panel?.classList.contains('collapsed')) document - .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) ?.click(); }); await page.waitForFunction( diff --git a/scripts/qa-form-fields.mjs b/scripts/qa-form-fields.mjs index 3bbee3816..11bdffc2b 100644 --- a/scripts/qa-form-fields.mjs +++ b/scripts/qa-form-fields.mjs @@ -276,7 +276,12 @@ try { ); await page.evaluate(() => { - for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + for (const panel of [ + 'pp-toggles', + 'utilities-panel', + 'scene-panel', + 'cctv-panel', + ]) window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { persist: false, syncShare: false, @@ -419,14 +424,24 @@ try { { timeout: 60000 }, ); await page.evaluate(() => { - for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + for (const panel of [ + 'pp-toggles', + 'utilities-panel', + 'scene-panel', + 'cctv-panel', + ]) window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { persist: false, syncShare: false, }); }); await page.evaluate(() => { - for (const panel of ['pp-toggles', 'scene-panel', 'cctv-panel']) + for (const panel of [ + 'pp-toggles', + 'utilities-panel', + 'scene-panel', + 'cctv-panel', + ]) window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, { persist: false, syncShare: false, diff --git a/scripts/qa-geo-import.mjs b/scripts/qa-geo-import.mjs index 074eb8fbd..c744b4825 100644 --- a/scripts/qa-geo-import.mjs +++ b/scripts/qa-geo-import.mjs @@ -269,10 +269,12 @@ try { }, VIEW); await wait(4000); await page.evaluate(() => { - const rail = document.getElementById('pp-toggles'); - if (rail?.classList.contains('collapsed')) + const panel = document.getElementById('utilities-panel'); + if (panel?.classList.contains('collapsed')) document - .querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]') + .querySelector( + '.panel-collapse-btn[data-collapse-target="utilities-panel"]', + ) ?.click(); }); await page.waitForFunction( diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index e6ce2e9f3..2ba88d3dd 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -62,9 +62,9 @@ test('the engine resolves manual geometry before any name resolution', () => { }); test('the Draw control markup carries the ids the tool binds to', () => { - // The DISPLAY rail is a component template now, assembled into index.html at + // UTILITIES is a component template now, assembled into index.html at // build time — the markup is not in index.html any more. - const html = read('src/ui/templates/display-controls.html'); + const html = read('src/ui/templates/layer-panels.html'); const ids = [ 'draw-toggle', 'draw-mode-row', @@ -140,8 +140,10 @@ test('nothing promises a GeoJSON export for drawn shapes', () => { } for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) { const source = read(file); - const at = source.indexOf('DISPLAY ▸ **Draw**'); - const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw'); + const start = + ['UTILITIES ▸ **Draw**', 'DISPLAY ▸ **Draw**', 'DISPLAY ▸ Draw'] + .map((text) => source.indexOf(text)) + .find((at) => at >= 0) ?? -1; assert.ok(start >= 0, `${file} should describe the Draw control`); const section = source.slice(start, start + 1200); assert.doesNotMatch( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 696233f86..9fdfac1b1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -761,6 +761,7 @@ "ESC to close": "ESC para cerrar", "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.", "SCENES": "ESCENAS", + "UTILITIES": "UTILIDADES", "Scene recipe": "Receta de escena", "NEW": "NUEVA", "DEL": "BORRAR", @@ -1470,6 +1471,7 @@ "NOIR": "NOIR", "Rendered globe frames per second · toggle with `": "Fotogramas por segundo renderizados del globo · alterna con `", "Utilities": "Utilidades", + "Audio": "Audio", "Other layers": "Otras capas", "Movement": "Movimiento", "Infrastructure": "Infraestructura", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8764d9d06..baf98e27c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -817,6 +817,7 @@ "Scene recipe": "Рецепт сцены", "DATA LAYERS": "СЛОИ ДАННЫХ", "SCENES": "СЦЕНЫ", + "UTILITIES": "ИНСТРУМЕНТЫ", "Close key setup": "Закрыть настройку ключей", "SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ", "Power up the globe": "Подключите потоки данных", @@ -1492,6 +1493,7 @@ "NOIR": "НУАР", "Rendered globe frames per second · toggle with `": "Кадров глобуса в секунду · переключение клавишей `", "Utilities": "Инструменты", + "Audio": "Аудио", "Other layers": "Другие слои", "Movement": "Движение", "Infrastructure": "Инфраструктура", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2e7db6b8f..d869e077a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1756,6 +1756,7 @@ "Events": "事件", "Weather": "天气", "Utilities": "工具", + "Audio": "音频", "Other layers": "其他图层", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对", @@ -2723,6 +2724,7 @@ "FLAT": "平面", "SINCE": "起始时间", "SCENES": "场景", + "UTILITIES": "工具", "Scene recipe": "场景方案", "NEW": "新建", "CAPTURE SHOT": "保存镜头", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 1477acd51..4e98cf9d6 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1746,6 +1746,7 @@ "Events": "事件", "Weather": "天氣", "Utilities": "工具", + "Audio": "音訊", "Other layers": "其他圖層", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", @@ -2711,6 +2712,7 @@ "FLAT": "平面", "SINCE": "起始時間", "SCENES": "場景", + "UTILITIES": "工具", "Scene recipe": "場景方案", "NEW": "新建", "CAPTURE SHOT": "保存鏡頭", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 83c8897d8..0c18df79a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -9,6 +9,7 @@ "AUTO", "Ambient", "Anime", + "Audio", "Austin", "BRG {value}°", "BRG —", diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 03ee75f08..686ee9af2 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,4 +1,9 @@ -import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; +import { + getScopeMaskFeather, + isScopeMaskEnabled, + setScopeMaskEnabled, + setScopeMaskFeather, +} from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -274,8 +279,14 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => { const { owner } = styleOwner(t, 'tactical'); - const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() }; - t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); }); + const previous = { + enabled: isScopeMaskEnabled(), + feather: getScopeMaskFeather(), + }; + t.after(() => { + setScopeMaskEnabled(previous.enabled); + setScopeMaskFeather(previous.feather); + }); Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather }); owner._scopeBtn = { checked: true }; owner._scopeFeatherSlider = { value: '11', disabled: false }; @@ -534,11 +545,11 @@ test('Cyber side panels share one width and one framed surface material', () => ); assert.match( cyberStyles, - /#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/, + /#left-panel-stack\s*> :is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/, ); assert.match( cyberStyles, - /:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/, + /:is\(#data-panel, #utilities-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/, ); assert.match( cyberStyles, diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index eb4bdfa99..d78731af5 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -439,7 +439,7 @@ test('the application composition wires the import and owns its teardown', () => ); assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt); const markup = fs.readFileSync( - new URL('./templates/display-controls.html', import.meta.url), + new URL('./templates/layer-panels.html', import.meta.url), 'utf8', ); for (const id of [ @@ -450,7 +450,7 @@ test('the application composition wires the import and owns its teardown', () => ]) assert.ok( markup.includes(`id="${id}"`), - `${id} missing from the DISPLAY template`, + `${id} missing from the UTILITIES template`, ); }); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 24bcad6e6..b654097b4 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -87,13 +87,20 @@ const PANEL_GROUPS = [ 'weather-cyclones', ], }, + // Directions renders in the Utilities panel, beside Draw and Import, and + // needs no heading there. { label: msg('Utilities'), - ids: ['directions', 'radio'], + ids: ['directions'], + containerId: 'utilities-toggles', + }, + { + label: msg('Audio'), + ids: ['radio'], }, ]; -const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) => - ids.map((id) => ({ id, label })), +const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) => + ids.map((id) => ({ id, label, containerId })), ); const PANEL_POSITIONS = new Map( PANEL_ORDER.map(({ id }, index) => [id, index]), @@ -226,6 +233,12 @@ export class LayerPanel { if (this._destroyed) return; this._releaseBindings(); this._toggleContainer = container; + this._groupContainers = new Map(); + for (const { containerId } of PANEL_GROUPS) { + const element = + containerId && container?.ownerDocument?.getElementById?.(containerId); + if (element) this._groupContainers.set(containerId, element); + } this._weatherPanel?.destroy(); this._weatherPanel = createWeatherPanel({ clock: this.weatherClock, @@ -276,11 +289,27 @@ export class LayerPanel { this._recentImageryPanel = null; this._recentImageryFactory = null; this._toggleContainer = null; + this._groupContainers = null; + } + /** The list a row renders into: its group's own, else Data Layers. */ + _containerFor(layerId) { + const containerId = PANEL_ORDER[PANEL_POSITIONS.get(layerId)]?.containerId; + return ( + (containerId && this._groupContainers?.get(containerId)) || + this._toggleContainer + ); + } + _rowFor(layerId) { + return this._containerFor(layerId)?.querySelector( + `[data-layer-id="${layerId}"]`, + ); } _renderToggles() { if (this._destroyed || !this._toggleContainer) return; this._releaseBindings(); this._toggleContainer.innerHTML = ''; + for (const element of this._groupContainers?.values() || []) + element.innerHTML = ''; const generation = this._generation; const layers = this.getAll() @@ -296,7 +325,12 @@ export class LayerPanel { const group = PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? msg('Other layers'); - if (group && group !== previousGroup) { + const rowContainer = this._containerFor(layer.id); + if ( + rowContainer === this._toggleContainer && + group && + group !== previousGroup + ) { const heading = document.createElement('h3'); heading.className = 'data-layer-group-heading'; heading.textContent = t(group); @@ -421,7 +455,7 @@ export class LayerPanel { } } - this._toggleContainer.appendChild(row); + rowContainer.appendChild(row); } this._refreshWeatherPanel(); } @@ -600,9 +634,7 @@ export class LayerPanel { return; } for (const layer of this.getAll()) { - const row = this._toggleContainer.querySelector( - `[data-layer-id="${layer.id}"]`, - ); + const row = this._rowFor(layer.id); if (!row) continue; const btn = row.querySelector('.data-toggle-btn'); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index d920e8545..edb8a4dca 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -30,6 +30,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'recent-imagery-panel', 'street-level-panel', 'scene-panel', + 'utilities-panel', 'pp-toggles', 'global-context-panel', 'radio-panel', diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d54e09f8d..6736ae193 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -2007,6 +2007,7 @@ body.cockpit-mode #left-panel-stack { max-height: none; transition: none; } +body.cockpit-mode #left-panel-stack > #utilities-panel, body.cockpit-mode #left-panel-stack > #scene-panel { display: none !important; } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 59f275109..8279ef30c 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -960,6 +960,7 @@ body.ui-clean-view #data-panel, body.ui-clean-view #cctv-panel, body.ui-clean-view #radio-panel, body.ui-clean-view #global-context-panel, +body.ui-clean-view #utilities-panel, body.ui-clean-view #scene-panel { opacity: 0 !important; visibility: hidden !important; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index e25bbae2d..715917b6e 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1662,7 +1662,9 @@ :root[data-ui-theme='cyber'] body:not(.cockpit-mode) #left-panel-stack:has(> [data-panel-id]:not(.collapsed)) - > :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) { + > :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not( + .collapsed + ) { flex: 1 1 auto; height: auto; max-height: none; @@ -1677,20 +1679,20 @@ :root[data-ui-theme='cyber'] #left-panel-stack - > :is(#data-panel, #scene-panel) { + > :is(#data-panel, #utilities-panel, #scene-panel) { --panel-expanded-width: var(--cyber-panel-width); width: var(--panel-expanded-width); } :root[data-ui-theme='cyber'] #left-panel-stack - > :is(#data-panel, #scene-panel).collapsed { + > :is(#data-panel, #utilities-panel, #scene-panel).collapsed { width: var(--cyber-panel-collapsed-width); } :root[data-ui-theme='cyber'] #left-panel-stack - > :is(#data-panel, #scene-panel).collapsed + > :is(#data-panel, #utilities-panel, #scene-panel).collapsed :is(.data-panel-inner, .scene-panel-inner) { box-sizing: border-box; height: 50px; @@ -1718,7 +1720,7 @@ :root[data-ui-theme='cyber'] body:not(.cockpit-mode) - :is(#data-panel, #scene-panel) + :is(#data-panel, #utilities-panel, #scene-panel) .panel-collapse-btn { width: var(--cyber-panel-control-size); min-width: var(--cyber-panel-control-size); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index ea64eefc5..2f4de3d18 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1398,3 +1398,101 @@ body.recording-mode #right-context-rail > [data-panel-id] { width: 100%; } } + +/* ── Utilities Panel ─────────────────────────── */ +/* Directions (from the layer list), Draw and Import. Draw and Import read as + rows like Directions above them (.data-toggle-row): icon and name, their + options indented under the name. */ +#utilities-panel { + --panel-expanded-width: 280px; + width: var(--panel-expanded-width); +} + +#utilities-panel .panel-glow { + position: absolute; + inset: -20px; + background: radial-gradient( + ellipse at center, + var(--accent-dim) 0%, + transparent 60% + ); + filter: blur(20px); + opacity: 0.25; + pointer-events: none; +} + +/* The layer list takes its natural height; the panel scrolls as a whole. */ +.utilities-list { + flex: 0 0 auto; + max-height: none; + overflow: visible; +} + +.utilities-tools { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 2px; +} + +.utilities-tools .pp-toggle-group { + width: 100%; +} + +#utilities-panel .utilities-tools .pp-toggle-btn { + width: 100%; + min-height: 0; + box-sizing: border-box; + gap: 8px; + padding: 8px 10px 6px; + color: var(--text-primary); + text-align: left; + background: transparent; + border: 0; + border-radius: 8px; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + transition: background var(--transition-fast); +} + +#utilities-panel .utilities-tools .pp-toggle-btn:hover { + background: rgba(255, 255, 255, 0.03); +} + +#utilities-panel .utilities-tools .pp-icon { + flex: none; + width: 16px; + font-size: 16px; + line-height: 1; + color: var(--text-primary); + text-align: center; +} + +#utilities-panel .utilities-tools .pp-label { + font-family: var(--font-sans); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: none; + color: var(--text-primary); +} + +/* Draw reads as on with the accent of an enabled layer row. */ +#utilities-panel + .utilities-tools + .pp-toggle-btn:is([aria-pressed='true'], .active) { + background: var(--accent-dim); + box-shadow: inset 2px 0 0 var(--accent); +} + +#utilities-panel + .utilities-tools + :is(.pp-slider-row, .geo-import-list, .geo-import-hint) { + margin-left: 34px; + margin-right: 10px; +} + +#utilities-panel.collapsed .utilities-tools { + display: none; +} diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index aecfdf8d4..481535434 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -166,6 +166,9 @@ body.mobile-shell #right-context-rail > #recent-imagery-panel { /* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD hiding collapsed siblings) never take a panel out of an open sheet. */ body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel, +body.mobile-shell[data-mobile-sheet='layers'] + #left-panel-stack + > #utilities-panel, body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel, body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail diff --git a/src/ui/styles/recording.css b/src/ui/styles/recording.css index be806a87a..97a326fd6 100644 --- a/src/ui/styles/recording.css +++ b/src/ui/styles/recording.css @@ -15,6 +15,7 @@ body.recording-mode #cctv-panel, body.recording-mode #radio-panel, body.recording-mode #global-context-panel, body.recording-mode #gev-voice-control, +body.recording-mode #utilities-panel, body.recording-mode #scene-panel { opacity: 0 !important; visibility: hidden !important; diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 8278b51a7..04dbf276b 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -237,11 +237,13 @@ #left-panel-stack > #cctv-panel, #left-panel-stack > #street-level-panel, #left-panel-stack > #global-context-panel, + #left-panel-stack > #utilities-panel, #left-panel-stack > #scene-panel, #left-panel-stack > #data-panel.collapsed, #left-panel-stack > #cctv-panel.collapsed, #left-panel-stack > #street-level-panel.collapsed, #left-panel-stack > #global-context-panel.collapsed, + #left-panel-stack > #utilities-panel.collapsed, #left-panel-stack > #scene-panel.collapsed { position: relative; top: auto; diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 29250a636..9cd15b797 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -14,6 +14,7 @@ #left-panel-stack > #data-panel, #left-panel-stack > #cctv-panel, #left-panel-stack > #global-context-panel, +#left-panel-stack > #utilities-panel, #left-panel-stack > #scene-panel { position: relative; top: auto; @@ -35,6 +36,10 @@ order: 2; } +#left-panel-stack > #utilities-panel { + order: 3; +} + #left-panel-stack > #scene-panel { order: 4; } @@ -50,6 +55,7 @@ #left-panel-stack > #data-panel:not(.collapsed), #left-panel-stack > #cctv-panel:not(.collapsed), +#left-panel-stack > #utilities-panel:not(.collapsed), #left-panel-stack > #scene-panel:not(.collapsed) { box-sizing: border-box; flex: 0 1 var(--left-panel-allocated-height, auto); @@ -62,7 +68,9 @@ * the canonical control order and center their combined stack at 50vh. */ #left-panel-stack.layout-tail - > :is(#data-panel, #cctv-panel, #scene-panel):not(.collapsed) { + > :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not( + .collapsed + ) { box-sizing: border-box; flex: 0 0 var(--left-panel-allocated-height); height: var(--left-panel-allocated-height); @@ -71,6 +79,7 @@ #left-panel-stack > #data-panel:not(.collapsed) .data-panel-inner, #left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner, +#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner, #left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner { height: 100%; max-height: 100%; @@ -81,6 +90,7 @@ } #left-panel-stack > #cctv-panel:not(.collapsed) .cctv-panel-inner, +#left-panel-stack > #utilities-panel:not(.collapsed) .utilities-panel-inner, #left-panel-stack > #scene-panel:not(.collapsed) .scene-panel-inner { overflow-y: auto; scrollbar-gutter: stable; @@ -360,6 +370,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #scene-panel.collapsed .scene-shot-list, #scene-panel.collapsed .scene-progress, #scene-panel.collapsed #scene-status, +#utilities-panel.collapsed .utilities-list, +#utilities-panel.collapsed .utilities-tools, #param-slider-panel.collapsed #param-sliders, #location-bar.collapsed .poi-row-container, #location-bar.collapsed .location-bar-divider, @@ -370,6 +382,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #data-panel.collapsed, #cctv-panel.collapsed, +#utilities-panel.collapsed, #scene-panel.collapsed { width: var(--left-collapsed-width); } @@ -381,6 +394,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #data-panel.collapsed .panel-title, #cctv-panel.collapsed .panel-title, #radio-panel.collapsed .panel-title, +#utilities-panel.collapsed .panel-title, #scene-panel.collapsed .panel-title { font-size: 8px; letter-spacing: 1.8px; @@ -391,6 +405,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #cctv-panel.collapsed .cctv-panel-inner, #radio-panel.collapsed .radio-panel-inner, #scene-panel.collapsed .scene-panel-inner, +#utilities-panel.collapsed .utilities-panel-inner, #param-slider-panel.collapsed, #location-bar.collapsed .location-inner { padding-bottom: 10px; @@ -402,6 +417,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #radio-panel.collapsed .panel-header, #global-context-panel.collapsed .panel-header, #scene-panel.collapsed .panel-header, +#utilities-panel.collapsed .panel-header, #param-slider-panel.collapsed .param-panel-header { margin-bottom: 2px; } diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index f6af31127..77361add5 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -117,41 +117,6 @@ 100% -
- -
- Shape -
- - - -
-
-
- - - - -
-
-
- - -
    - -
    + + +