diff --git a/changelog.d/1086-prohibited-restricted-airspace.md b/changelog.d/1086-prohibited-restricted-airspace.md index 35b07130c..c697dd214 100644 --- a/changelog.d/1086-prohibited-restricted-airspace.md +++ b/changelog.d/1086-prohibited-restricted-airspace.md @@ -5,4 +5,4 @@ prohibited, orange restricted); selection shows name, altitude range, times of use and agency, and the row legend states the layer is informational only and does not include the UAS Facility Map or UAS flight restrictions. Off by - default; share-link token `6` (mvanhorn, #1086). + default; share-link token `8` (mvanhorn, #1086). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fc327bfc6..7941f8112 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -112,9 +112,11 @@ Selecting a zone shows name, an altitude range built from the agency, plus a notice that the layer is informational only and not a substitute for official UAS flight-planning tools. Pick ownership is unregistered so sibling -layer cards still work over a zone. Voice can toggle the layer and query the -loaded records. The layer covers P and R zones only, not the FAA UAS Facility -Map or National Security UAS Flight Restrictions; the label and legend say so. +layer cards still work over a zone. Voice is off for this layer for now +(`VOICE_OFF_LAYERS`: the Realtime prompt has no room), so neither voice nor the +analyst can toggle or query it. The layer covers FAA prohibited and restricted +special-use airspace only, not the UAS Facility Map / LAANC grids, National +Security UAS Flight Restrictions or TFRs; the label and legend say so. See the live-layers table below. Vessel sources can report healthy empty coverage using a current, complete @@ -591,7 +593,7 @@ Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. -Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, drone restrictions, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. +Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6baee6327..494318dca 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -413,6 +413,7 @@ "src/layers/droneAirspace/source.test.mjs", "src/layers/droneAirspace/cards.test.mjs", "src/layers/droneAirspace/ownership.test.mjs", + "src/layers/droneAirspace/docs.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", diff --git a/src/layers/droneAirspace/docs.test.mjs b/src/layers/droneAirspace/docs.test.mjs new file mode 100644 index 000000000..707cfa3a3 --- /dev/null +++ b/src/layers/droneAirspace/docs.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js'; +import { ANALYST_LAYERS } from '../../data/analystEngine.js'; + +const read = (path) => + readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8'); +const currentState = read('docs/CURRENT-STATE.md'); +const section = currentState.slice( + currentState.indexOf('## Prohibited & Restricted Airspace'), + currentState.indexOf('\n## ', currentState.indexOf('## Prohibited')) + 1, +); +const tokens = JSON.parse(read('src/data/layerStateTokenReservations.json')); + +test('the changelog fragment names the reserved share-link token', () => { + const token = JSON.stringify(tokens).match( + /\["drone-airspace","([^"]+)"\]/, + )?.[1]; + assert.ok(token, 'drone-airspace has a reserved token'); + const fragment = read('changelog.d/1086-prohibited-restricted-airspace.md'); + assert.match(fragment, new RegExp(`share-link token \`${token}\``)); + assert.match(section, new RegExp(`share-link token \`${token}\``)); +}); + +test('CURRENT-STATE matches the layer being outside voice and the analyst', () => { + assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'drone-airspace')); + assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'drone-airspace')); + assert.doesNotMatch(section, /Voice can toggle/); + assert.match(section, /Voice is off for this layer for now/); + const analyst = currentState.match(/Analyst records come on demand[^\n]*/)[0]; + assert.doesNotMatch(analyst, /drone/i); +}); + +test('CURRENT-STATE states what the layer does not cover', () => { + assert.match(section, /UAS Facility Map/); + assert.match(section, /LAANC/); + assert.match(section, /TFRs/); +});