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 1/9] 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 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 2/9] 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 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 3/9] 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 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 4/9] 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 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 5/9] 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 6/9] 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 328894d566df8cac43f373f73f942ac37fb1fbdc Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:59:48 -0700 Subject: [PATCH 7/9] docs(changelog): state the voice hint saving without main's size Co-Authored-By: Claude Opus 5.5 --- changelog.d/1462-voice-alias-hint-budget.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md index 64cf56100..82628c52c 100644 --- a/changelog.d/1462-voice-alias-hint-budget.md +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -1,5 +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 + `local-dams`, out of the voice layer hint. The Realtime prompt is 305 + characters shorter, leaving room under the 52,000 gate for new voice-toggleable layers; every alias is still accepted (quanticsoul4772, #1462). From 8a23328ed13319c91f07295feba76f6a06961a4d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:00:48 -0800 Subject: [PATCH 8/9] Describe multipart cable ownership by behaviour and credit the author's handle --- .../1093-multipart-cable-feature-ownership.md | 2 +- docs/CURRENT-STATE.md | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/changelog.d/1093-multipart-cable-feature-ownership.md b/changelog.d/1093-multipart-cable-feature-ownership.md index 65892e9a9..777a159d0 100644 --- a/changelog.d/1093-multipart-cable-feature-ownership.md +++ b/changelog.d/1093-multipart-cable-feature-ownership.md @@ -1,4 +1,4 @@ - 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 - (Aliciawque, #1093). + (@Aliciawque, #1093). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6d6a52f09..3b66fc24d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6371,14 +6371,11 @@ 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. The application viewer disables the Cesium info box, -so those default descriptions are not shown in the app. +Every Cesium entity from multipart cable or landing-point geometry stays tied +to its source feature, including generated IDs and IDs that collide with +Cesium's multipart suffixes. Each segment gets its own feature's styling, click +destination and horizon-culling reference, and each source feature has one +reference stem. The source collections are not modified. Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution From 9fecc9c4ee7c469d0cc9cee3f571ff6a2b6fa6df Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:00:03 -0800 Subject: [PATCH 9/9] Say the alias hint filters only the first two aliases, and drop an id the filter now covers --- changelog.d/1462-voice-alias-hint-budget.md | 2 +- src/voice/layerManifest.js | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md index 82628c52c..ae0b1be38 100644 --- a/changelog.d/1462-voice-alias-hint-budget.md +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -1,5 +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 is 305 characters shorter, leaving room under the 52,000 gate for new - voice-toggleable layers; every alias is still accepted (quanticsoul4772, + voice-toggleable layers; every alias is still accepted (@quanticsoul4772, #1462). diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index faa7207d0..962bf6949 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,8 +525,6 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', - // The brand is already the layer id; all spoken aliases remain accepted. - 's2-ghostmaps', ]); /** Lower case with spaces and hyphens removed: "Data centers" → "datacenters". */ @@ -535,9 +533,9 @@ 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. 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. + * the tool JSON small; the runner accepts the full alias list. Of those two, + * an alias the id already spells ("dams"→local-dams, "weather radar"→ + * weather-radar) is left out, and a layer with neither left gets no hint. * @returns {string} One line for the layerId description. */ export function voiceLayerAliasHint() {