Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini Feizabadi 35aef1cdea docs: assign altitude filter release note to PR #1290 2026-10-20 17:14:32 +02:00
Mazeyar Moeini Feizabadi 2ab7c0d8e6 fix(local-adsb): track altitude freshness and held-value provenance 2026-10-20 17:12:24 +02:00
Mazeyar Moeini Feizabadi 68d87aa107 Merge PR #801 altitude plausibility filter from Lob26
Preserve Pedro Lobato’s original commits. Move his credited release note into a changelog fragment when resolving the current-main conflict.
2026-10-20 16:48:28 +02:00
Pedro Lobato 2be50ae41c docs(local-adsb): credit the altitude filter to its PR in CHANGELOG 2026-09-28 10:57:32 -05:00
Pedro Lobato 96d8000620 fix(local-adsb): hold an altitude no aircraft could have reached
A corrupt frame can still pass CRC, and one reached the globe as a DC-9 at
108,800 ft (#714). The browser decoder cannot produce that value: it decodes
only 25 ft (Q=1) altitudes, which stop at 50,175 ft. It came from a 1090
decoder feed, so the check sits in the layer's per-aircraft ingest, where the
browser SDR and the feeds already meet.

Each altitude must be reachable from the last accepted one at 1.5 x the
larger reported vertical rate + 2,000 ft/min (12,500 ft/min with none) over
the elapsed time + 1 s, plus 300 ft, which is the position speed check's
shape. A refused report holds the last accepted altitude on the record
itself, so the marker height, the card, the model ceiling and route
plausibility read the same value. Only a newer message is judged, because the
layer re-reads one record several times a second and a re-read must not count
toward the three-refusal re-anchor or let the refused value through. Surface
reports pass unjudged, since a feed's "ground" is 0 and not a barometric
reading. Refusals are counted as rejectedAltitudes in the layer stats.
2026-09-28 10:56:54 -05:00
78 changed files with 2308 additions and 2522 deletions

No files matched your search

+1 -1
View File
@@ -425,7 +425,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
-3
View File
@@ -1,3 +0,0 @@
- Report actual outcomes when voice enables or disables CCTV through the shared
layer lifecycle, including failed and cancelled changes (manjunath22466,
#1205).
-3
View File
@@ -1,3 +0,0 @@
- Require a geocoded containment anchor for ambiguous analyst region names,
preserve boundary provenance and cancellation safety, and retain exact-name
precedence for natural outlines (manjunath22466, #1235).
@@ -1,3 +0,0 @@
- Gate CCTV HLS media requests before session work, bind leases to the direct
peer, and bound per-peer leases across shared sessions (asturksever, #1267).
- Keep headful CCTV QA rendering active when its window is occluded (asturksever, #1267).
-4
View File
@@ -1,4 +0,0 @@
- The Street Level SINCE note sits on the value's line, in its type and a
size smaller: with KartaView lit it reads ANY DATE (by upload date). It
had lost its size variable in the Street Level slim-down and rendered
larger than the value, on a line of its own (asturksever, #1286).
-3
View File
@@ -1,3 +0,0 @@
- Give ordinary sliders a shared native control and consistent track, thumb,
value, focus and disabled styling while preserving feature behavior and
specialized controls (mazy1998, #1287).
@@ -0,0 +1,10 @@
- Local ADS-B filters implausible altitude changes and holds the accepted
value consistently for markers, cards and models. Browser refusals count
actual altitude frames rather than unrelated aircraft messages. Decoder
feeds use a conservative five-second recovery with three advancing
snapshots and aircraft message activity, explicitly marked as inferred.
Held values retain their altitude source and measurement time through
context and analyst results; feeds report an unknown measurement time.
Cached snapshots and transport jitter cannot recount refusals. Ground
reports reset the airborne reference, and no absolute altitude ceiling is
imposed (Pedro Lobato's original filter, #801, with maintainer corrections).
@@ -1,5 +0,0 @@
- Unify segmented selectors for model coverage, detection allocation, drawing
shape, Street View and imagery comparison while preserving their existing
behavior. Keep compact labels such as FILL and FLAT on one line, and let
collapsed Weather release space to the open rail panel. Reserve the full mode
button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301).
+47 -53
View File
@@ -1,21 +1,5 @@
# God's Eye View Current State
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
the same name contain that anchor, the lookup stays unresolved rather than
selecting the largest entry. Natural outlines try exact names before aliases and
broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery.
CCTV live-media requests are same-site gated before catalog or media work, including
HLS renewal, segment reads and DELETE cleanup. HLS leases belong to their socket
peer (IPv4-mapped IPv6 is normalized), with eight leases per peer across the two
global sessions. Peers behind the same NAT share that budget; this is resource
admission, not authentication. Native same-origin video and range requests remain
supported.
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
canonical; `greatCircleMeters` is its exact metre conversion. Missing,
@@ -640,14 +624,6 @@ The standalone app offers OpenAI and Gemini Live through an explicit provider ch
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields.
`createGevActionRunner` routes `control_cctv` enable/disable through the shared
layer visibility lifecycle. Results distinguish successful changes from
manager-reported failures or cancellation and report enabled state and lifecycle
uncertainty. A caller-supplied abort signal reaches the manager; a late abort
preserves an already committed change. Missing-layer errors and other CCTV
actions retain their existing behavior. The exported `controlCctv` helper refuses direct
enable/disable requests and directs callers to `set_layer_visibility`.
`src/voice/layerManifest.js` declares each catalog layer's voice capabilities: spoken aliases, `toggle`, in-view `context`, `track`, and analyst `query` fields with type and unit, or `noQuery` with the reason. The `set_layer_visibility`, `show_data_layers_menu`, `get_entity_context` and `analyst_query` layer enums, the runner's alias table and the analyst field hints are generated from it. `src/voice/layerManifest.test.mjs` fails when a catalog layer is neither listed nor named in `VOICE_OFF_LAYERS` (today only the two scene-driven Bhote Koshi layers). Global Context is listed with `toggle: false`; `set_context_mode` reaches it. `set_panel_open` also opens `weather-panel` and `recent-imagery-panel`.
Portable source exports provide Radio Browser station normalization, CCTV feed types and regional records independently of HTTP middleware. Radio normalization accepts an explicit URL policy; the standalone directory retains its existing HTTPS rules. Tile coordinate validation accepts explicit zoom bounds with unchanged traffic defaults. Cache, request and rendering owners remain unchanged.
@@ -1231,32 +1207,6 @@ The application supplies actions and retains effect settings, restore claims and
rendering. Native keyboard editing, model modes and current defaults are preserved.
Destroying the UI removes these listeners before asynchronous teardown.
Ordinary sliders share the native range component in `ui/slider.js` and the
`gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader
parameters, volume, replay speed, weather settings/history, imagery opacity and
Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state.
Generated controls use `createSlider`; static templates apply the same class.
Feature owners retain bounds, steps, formatted values, input/change timing and
listener cleanup. The radio tuning dial, imagery comparison divider and event
comparison/progression controls keep their specialized presentation and behavior.
## Segmented selectors
Segmented selectors use the controlled DOM adapter in `ui/segmentedControl.js`
and the shared `gev-segmented` / `gev-segment` surface. Model coverage, label
allocation, drawing shape, Street View fit/panorama filters and imagery mode keep
their feature-owned values, actions and persistence. Painting a restored value
does not emit a change or move focus. Existing keyboard policies remain explicit:
Street View uses arrows/Home/End with one tab stop, imagery mode uses arrows from
its controlled selection, and Display keeps its existing native button behavior.
Pure value navigation is separate from DOM rendering for reuse by a future view.
Segments keep their word minimum width: compact inline groups such as FIT/FILL
and ALL/360°/FLAT must not break short labels mid-word. Collapsed Weather follows
the same exclusive-rail hiding rule as the other readouts.
The imagery mode row reserves 26px for the 24px buttons and group borders so
the complete button and inset keyboard-focus outline remain visible.
Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented-controls.mjs`.
## Application shortcuts and shader parameter controls
`ui/input` supplies the bubbling application shortcut listener and generated
@@ -3718,8 +3668,8 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
`fieldOfView`) and upload date and checks each answer the same way (when
photos are near but none passes, the panel says so), and the cones pass on
each photo's projection and upload date. The provider declares
`sinceBasis: 'upload'`, so while it is lit the panel shows "(by
upload date)" beside the SINCE value, as ANY DATE (by upload date) (`sinceNote` in the UI state). Tiles come uncompressed and
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
@@ -5739,6 +5689,46 @@ on purpose:
Refused fixes are counted (`positionsRejected` in the SDR state; the layer
applies the same check to merged feed records and reports the total as
`rejectedPositions` in its stats). Three refusals in a row re-anchor.
- **Altitude filter.** A corrupt frame can pass CRC, and a decoder feed can
relay one: a DC-9 was logged at 108,800 ft. The browser decoder cannot
produce that value, since it decodes only 25 ft (Q=1) altitudes and those
stop at 50,175 ft, so the check runs in the layer where both paths merge
(`gateLocalAdsbAltitude` in `src/sources/adsbRecords.js`). Each new report
must be reachable from the last accepted altitude at 1.5 × the larger
reported vertical rate + 2,000 ft/min (12,500 ft/min without a rate) over
the elapsed time + 1 s, plus 300 ft. A refused report holds the last
accepted altitude for the marker height, the card, the model ceiling and
route plausibility alike, and is counted as `rejectedAltitudes` in the
layer stats. Browser tracks carry the altitude frame's own receive time,
sequence and track start: identification/velocity messages and renderer
re-reads never refresh an accepted altitude or recount a refusal. Equal
altitudes in distinct frames are real observations, including frames from
one USB block with the same timestamp. Three observed refusals in a row
re-anchor; a sequence gap counts as only one observed report.
Decoder feeds expose no altitude-specific measurement age. A changed value
is judged once and keeps its receipt-time reference through unrelated
messages. Feed allowance is capped at a 60 s relative gap, since fresh
positions can keep an old altitude alive indefinitely. Recovery from an
implausible feed value requires a coherent candidate stream for at least
5 s and three advancing source snapshots with increasing aircraft message
counts from one receiver. Cached snapshots and transport jitter cannot
supply that progress. Each receiver keeps separate recovery progress when
the freshest position alternates between inputs; a new accepted altitude
resets pending recovery against the old reference. This is
an inference: a stable corrupt feed can also satisfy it. Without source
clock/count/identity metadata, automatic feed recovery is disabled, so a
held altitude can persist while positions remain fresh. A surface report
clears the airborne reference, because a feed's `ground` is 0 and not a
barometric reading. The next airborne value seeds a new reference, just
like the first observation of a new aircraft. There is no absolute ceiling:
balloons really do fly above 100,000 ft.
Accepted/held records carry `altitudeQuality` (`accepted`,
`held-after-rejection`, `feed-estimate` or `inferred-reanchor`),
`altitudeObservedAt` (the actual browser measurement time, null for feeds),
and `altitudeSource` (the accepted value's source, which can differ from the
position source). These accompany altitude through context metadata and
final analyst items, including altitude-only queries. Missing/surface
altitudes carry no airborne quality claim.
- **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft),
with the tracked-flight trail look and a live head segment. No network
@@ -5757,10 +5747,12 @@ aircraft join detection boxes, labelled only with a decoded callsign.
Receivers publish one record shape from `src/sources/adsbRecords.js`
(`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`,
`altitudeObservedAt`, browser `altitudeSequence`/`altitudeSessionAt`, feed
`feedId`/`feedSnapshotAt`,
`groundSpeedKt`, `trackDeg`, `verticalRateFpm`, `lastPositionAt`,
`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/
`normalizeDump1090Aircraft(json, nowMs, { band, feedId })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
@@ -5785,6 +5777,8 @@ second only while the layer is enabled and only while the route reports
`configured`; the Local RTL-SDR card makes one probe request at startup to show
its read-only "Decoder feeds: 1090 live · 978 live" line. Feed records are
rebased to the browser clock and kept up to 60 s after their last message.
The opaque configured `feedId` and raw `feedSnapshotAt` source clock are
preserved without rebasing; neither exposes the receiver URL.
`mergeLocalAdsbRecords` merges both inputs by ICAO, keeping the newest position
(tie: newest message) and the bands/sources heard in the last 60 s; the card's
receiver line names them ("Heard by your receiver · 1090 MHz + 978 MHz UAT ·
+20
View File
@@ -18,6 +18,26 @@ smoothly between fixes, while local receiver aircraft are shown as heard. An
aircraft in both layers therefore draws twice, the magenta local marker ahead
of the public one; both markers are drawn on purpose.
Airborne altitude changes are checked against the last accepted value and a
vertical-rate allowance. A refused value holds the accepted altitude for the
marker, card and model. Browser SDR recovery counts actual altitude frames;
identification and velocity messages cannot make a held altitude look new.
Decoder feeds have no altitude measurement timestamp. Their changed values
are estimates; recovery after a refusal requires at least five seconds and
three advancing source snapshots with increasing aircraft message counts
from the same receiver, with coherent altitude changes. Repeated cached
snapshots and transport delays do not count. This recovery is inferred, so a
stable corrupt feed can still qualify. Feeds missing progression metadata
cannot automatically recover a held value, and fresh positions can keep that
held altitude alive. The rate check caps its elapsed allowance at 60 seconds
for feeds; there is no absolute altitude ceiling.
Context and analyst records include `altitudeQuality`, `altitudeObservedAt`
and `altitudeSource`. Held values retain the accepted value's source and
measurement time; feed measurement times are null. Ground reports clear the
airborne reference and its quality metadata.
## Which to use
- **One plain RTL-SDR dongle, desktop Chrome or Edge:** use the browser SDR.
-4
View File
@@ -190,10 +190,7 @@
"src/ui/visualInput.js",
"src/ui/visualInput.test.mjs",
"scripts/qa-visual-input.mjs",
"scripts/qa-segmented-controls.mjs",
"src/ui/displayControls.js",
"src/ui/segmentedControl.test.mjs",
"src/ui/styles/segmented-control.css",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
@@ -304,7 +301,6 @@
"src/ui/styles/status.css",
"src/ui/styles/layers.css",
"src/ui/styles/controls.css",
"src/ui/styles/slider.css",
"src/ui/styles/cctv.css",
"src/ui/styles/foundation.css",
"src/ui/styles/first-run.css",
+1 -11
View File
@@ -187,8 +187,6 @@
"exports": ["./server/providers/cctv"],
"modules": [
"server/providers/cctv.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs",
"server/providers/cctv/cap.js",
"server/providers/cctv/groundHeights.js",
"server/providers/cctv/catalog.js",
@@ -424,7 +422,6 @@
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
@@ -434,7 +431,7 @@
},
"display-controls": {
"exports": ["./ui/display"],
"modules": ["src/ui/displayControls.js", "src/ui/segmentedControl.js"],
"modules": ["src/ui/displayControls.js"],
"external": []
},
"visual-effects": {
@@ -483,7 +480,6 @@
"src/ui/weatherPanel.js",
"src/ui/railCards.js",
"src/ui/railCardBlocks.js",
"src/ui/slider.js",
"src/ui/chipGroup.js",
"src/ui/rowList.js",
"src/ui/railTimeline.js",
@@ -705,7 +701,6 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -744,7 +739,6 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -2182,7 +2176,6 @@
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/segmentedControl.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
@@ -2221,7 +2214,6 @@
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
@@ -3215,13 +3207,11 @@
"src/ui/applicationShortcuts.js",
"src/ui/effects.js",
"src/ui/styleParameters.js",
"src/ui/slider.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/ui/visualSettings.js",
"src/ui/segmentedControl.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
-1
View File
@@ -454,7 +454,6 @@ async function main() {
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-backgrounding-occluded-windows',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
-322
View File
@@ -1,322 +0,0 @@
#!/usr/bin/env node
/** Run against a dev server or built preview with QA_BASE_URL (default :4179). */
import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
headless: true,
args: ['--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
const errors = [];
const failures = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed, detail = '') => {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures.push(name);
};
const base = process.env.QA_BASE_URL || 'http://localhost:4179';
const shots = process.env.QA_SHOTS_DIR || 'qa-shots/segmented-controls';
fs.mkdirSync(shots, { recursive: true });
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
return {
groups: groups.length,
buttons: buttons.length,
shared:
groups.every((g) => g.classList.contains('gev-segmented')) &&
buttons.every((b) => {
const style = getComputedStyle(b);
return (
b.classList.contains('gev-segment') &&
style.fontSize === '9px' &&
style.minHeight === '24px' &&
style.borderRadius === '0px'
);
}),
};
});
check(
'all six one-of-many groups use the shared surface',
surface.groups === 6 && surface.buttons === 15 && surface.shared,
JSON.stringify(surface),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
check(
'model selection reaches both aircraft layers',
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
return (
['flights', 'military'].every(
(id) => dm.layers.get(id).module.getParams().models3dMode === 'all',
) &&
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true'
);
}),
);
await page.focus('#models3d-mode-all');
await page.keyboard.press('ArrowLeft');
check(
'Display retains its click-only arrow policy',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-all')
.getAttribute('aria-checked') === 'true',
),
);
await page.focus('#models3d-mode-proximity');
await page.keyboard.press('Space');
check(
'native Space selects model coverage',
await page.evaluate(
() =>
document
.getElementById('models3d-mode-proximity')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.getElementById('detection-allocation-weighted').click(),
);
check(
'allocation selection still updates application tuning',
await page.evaluate(
() =>
window.__godsEyeView.styleManager._detectionAllocationPreference ===
'WEIGHTED' &&
document
.getElementById('detection-allocation-weighted')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('[data-shape="pin"]').click(),
);
check(
'drawing shape still uses its existing feature action',
await page.evaluate(
() =>
window.__gevDrawTool?.shape === 'pin' &&
document
.querySelector('[data-shape="pin"]')
.getAttribute('aria-checked') === 'true',
),
);
// Off-layer controls still own params; no catalog/provider request is needed.
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').click(),
);
check(
'panorama choice reaches the Street Level layer',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'flat',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-pano="flat"]').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home',
bubbles: true,
cancelable: true,
}),
),
);
check(
'Street Level Home retains selection and roving tab state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams().pano === 'all' &&
document
.querySelector('[data-sl-pano="all"]')
.getAttribute('tabindex') === '0',
),
);
await page.evaluate(() =>
document.querySelector('[data-sl-render="fill"]').click(),
);
check(
'fit/fill keeps its stored render preference',
await page.evaluate(
() =>
localStorage.getItem('gev:street-level:render-mode') === 'fill' &&
document
.querySelector('[data-sl-render="fill"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() =>
document.querySelector('.ri-mode-btn[data-mode="ab"]').click(),
);
check(
'imagery mode reaches its controlled layer state',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'ab',
),
);
await page.evaluate(() =>
document.getElementById('ri-mode').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
}),
),
);
check(
'imagery arrows wrap from the current selected value',
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('recent-imagery')
.module.getSnapshot().mode === 'image' &&
document
.querySelector('.ri-mode-btn[data-mode="image"]')
.getAttribute('aria-checked') === 'true',
),
);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager._setDetectionAllocation('ELASTIC', {
syncShare: false,
persist: false,
});
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' });
});
await page.screenshot({ path: `${shots}/desktop.png` });
await page.setViewport({ width: 620, height: 900 });
await page.evaluate(() =>
document
.getElementById('models3d-mode-proximity')
.scrollIntoView({ block: 'center' }),
);
await page.screenshot({ path: `${shots}/narrow.png` });
check(
'narrow selectors stay within their panel',
await page.evaluate(() =>
[...document.querySelectorAll('.pp-mode-seg')]
.filter((g) => g.getClientRects().length)
.every((group) => group.scrollWidth <= group.clientWidth + 1),
),
);
const cyber = await page.evaluate(() => {
const root = document.documentElement;
const previous = root.getAttribute('data-ui-theme');
root.setAttribute('data-ui-theme', 'cyber');
const selected = [
...document.querySelectorAll('.gev-segment[aria-checked="true"]'),
];
// Check the final theme colors, independent of software-GL frame cadence.
for (const button of selected)
for (const animation of button.getAnimations()) animation.finish();
const backgrounds = selected.map(
(button) => getComputedStyle(button).backgroundColor,
);
const radii = [...document.querySelectorAll('.gev-segmented')].map(
(group) => getComputedStyle(group).borderRadius,
);
const consistent =
selected.length === 6 &&
backgrounds.every((color) => color === backgrounds[0]) &&
radii.every((radius) => radius === '1px');
if (previous === null) root.removeAttribute('data-ui-theme');
else root.setAttribute('data-ui-theme', previous);
return { consistent, count: selected.length, backgrounds, radii };
});
check(
'Cyber theme styles every selected segment consistently',
cyber.consistent,
JSON.stringify(cyber),
);
await page.evaluate(async () => {
const { dataManager, styleManager } = window.__godsEyeView;
await dataManager.setEnabled('recent-imagery', true);
styleManager.setPanelCollapsed('recent-imagery-panel', false, {
persist: false,
syncShare: false,
});
});
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
await page.focus('.ri-mode-btn[aria-checked="true"]');
const modeLayout = await page.evaluate(() => {
const group = document.getElementById('ri-mode');
const bounds = group.getBoundingClientRect();
const style = getComputedStyle(group);
const top = bounds.top + parseFloat(style.borderTopWidth);
const bottom = bounds.bottom - parseFloat(style.borderBottomWidth);
const buttons = [...group.querySelectorAll('[role="radio"]')];
return {
visible: bounds.width > 0 && bounds.height > 0,
contained: buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.top >= top - 0.5 && rect.bottom <= bottom + 0.5;
}),
groupHeight: bounds.height,
buttonHeights: buttons.map(
(button) => button.getBoundingClientRect().height,
),
};
});
check(
`imagery mode buttons and focus outline fit their row at ${width}px`,
modeLayout.visible && modeLayout.contained,
JSON.stringify(modeLayout),
);
}
check('no uncaught browser errors', errors.length === 0, errors.join(' | '));
} catch (error) {
console.error('Browser errors:', errors);
await page.screenshot({ path: `${shots}/failure.png` }).catch(() => {});
throw error;
} finally {
await browser.close();
}
console.log(`RESULT: ${failures.length} failures`);
process.exitCode = failures.length ? 1 : 0;
-41
View File
@@ -661,32 +661,6 @@ async function main() {
},
);
await step(
'FIT/FILL keeps native selection, keyboard focus and persistence with a photo open',
async () => {
await page.click('[data-sl-render="fill"]');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
assert.equal(
await page.evaluate(() =>
localStorage.getItem('gev:street-level:render-mode'),
),
'fill',
);
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.street.renderMode === 'letterbox');
assert.deepEqual(
await page.$eval('[data-sl-render="letterbox"]', (node) => ({
selected: node.getAttribute('aria-checked'),
tabIndex: node.tabIndex,
focused: document.activeElement === node,
})),
{ selected: 'true', tabIndex: 0, focused: true },
);
await page.keyboard.press('End');
await uiUntil(page, (u) => u.street.renderMode === 'fill');
},
);
await step('× closes the photo and deselects its sequence', async () => {
await press(page, '#sl-viewer-close');
await uiUntil(
@@ -699,21 +673,6 @@ async function main() {
);
});
await step(
'panorama segments keep native arrow and Home navigation',
async () => {
await page.click('[data-sl-pano="flat"]');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('ArrowRight');
await uiUntil(page, (u) => u.filter.pano === 'all');
await page.keyboard.press('End');
await uiUntil(page, (u) => u.filter.pano === 'flat');
await page.keyboard.press('Home');
await uiUntil(page, (u) => u.filter.pano === 'all');
await waitForCoverage(page);
},
);
await keylessProviderStep({
id: 'kartaview',
name: 'KartaView',
-32
View File
@@ -183,38 +183,6 @@ try {
return manager.bloomEnabled !== before;
}),
);
const sliders = await page.evaluate(() => {
const ordinary = [
...document.querySelectorAll('input[type="range"]'),
].filter(
(input) => !input.matches('#radio-tuner-slider, .bhote-event-range'),
);
return {
count: ordinary.length,
shared: ordinary.every((input) =>
input.classList.contains('gev-quantitative-slider'),
),
surface: ordinary.every((input) => {
const style = getComputedStyle(input);
return (
style.height === '18px' &&
style.appearance === 'none' &&
style.padding === '0px' &&
style.borderWidth === '0px' &&
(!input.disabled ||
(style.opacity === '0.42' && style.cursor === 'not-allowed'))
);
}),
tunerSeparate: !document
.getElementById('radio-tuner-slider')
.classList.contains('gev-quantitative-slider'),
};
});
check(
'ordinary sliders share one native surface',
sliders.count > 10 && sliders.shared && sliders.surface,
);
check('radio tuning keeps its specialized control', sliders.tunerSeparate);
fs.mkdirSync('qa-shots/visual-input', { recursive: true });
await page.screenshot({ path: 'qa-shots/visual-input/desktop.png' });
await page.setViewport({ width: 620, height: 900 });
+3 -5
View File
@@ -1745,9 +1745,8 @@ async function runBehaviorLayer() {
'behavior: analyst follow-up re-filters the remembered set',
`count=${r?.count} followUp=${r?.coverage?.followUp}`,
);
// Require the bundled boundary source and the existing whole-query limit.
// This elapsed time includes record acquisition and filtering; the region
// resolver has its own separate fallback budget.
// A marine region resolves from the bundled Natural Earth pack in the page,
// without the slower geocode fallback.
const gulfStarted = Date.now();
r = await run('analyst_query', {
layers: ['flights'],
@@ -1758,10 +1757,9 @@ async function runBehaviorLayer() {
report(
r?.ok === true &&
String(r?.coverage?.scope || '').includes('Gulf of Mexico') &&
r?.coverage?.scopeSource === 'natural-earth' &&
gulfMs < 3000,
'behavior: analyst resolves the Gulf of Mexico from the bundled pack',
`scope=${r?.coverage?.scope} source=${r?.coverage?.scopeSource} ms=${gulfMs} err=${r?.error || ''}`,
`scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`,
);
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
+4 -19
View File
@@ -17,7 +17,6 @@ import {
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { admitSameSite } from './common/same-site.js';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
@@ -136,18 +135,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
});
server.middlewares.use('/api/cctv', async (req, res) => {
try {
const url = new URL(req.url || '/', 'http://localhost');
// Refuse before catalog I/O, session creation, renewal or release.
if (url.pathname.startsWith('/media/') && admitSameSite(req, res))
return;
const clientId = String(req.socket?.remoteAddress || 'unknown').replace(
/^::ffff:/,
'',
);
const sources = await getCctvSources();
const sourceById = new Map(
sources.map((source) => [source.id, source]),
);
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname === '/sources') {
const body = {
@@ -246,7 +238,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
return;
}
if (req.method === 'DELETE') {
if (leaseId) puller.release(cameraId, leaseId, clientId);
if (leaseId) puller.release(cameraId, leaseId);
res.writeHead(204);
res.end();
return;
@@ -276,7 +268,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
url.searchParams.get('session'),
Number(match[3]),
leaseId,
clientId,
);
res.writeHead(body ? 200 : 404, {
'Content-Type': 'video/mp2t',
@@ -288,15 +279,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const downstream = watchDownstreamClose(res);
let entry;
const cancelPending = () => {
if (entry) puller.release(cameraId, leaseId, clientId);
if (entry) puller.release(cameraId, leaseId);
};
try {
entry = await puller.ensure(
cameraId,
mediaUrl,
leaseId,
clientId,
);
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
if (downstream.closed) {
cancelPending();
return;
@@ -310,7 +296,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
entry,
cameraId,
leaseId,
clientId,
);
if (downstream.closed) return;
if (!playlist) throw new Error('Stream unavailable');
+13 -46
View File
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
export const HLS_LIMITS = Object.freeze({
sessions: 2,
leasesPerSession: 8,
leasesPerClient: 8,
playlistBytes: 256 * 1024,
segmentBytes: 4 * 1024 * 1024,
sessionBytes: 24 * 1024 * 1024,
@@ -139,37 +138,25 @@ export function createHlsPuller({
entry.upstream.clear();
entry.bytes = 0;
};
const release = (cameraId, leaseId, clientId = 'legacy') => {
const release = (cameraId, leaseId) => {
const entry = active.get(cameraId);
const lease = entry?.leases.get(leaseId);
if (!lease || lease.clientId !== clientId) return;
if (!lease) return;
clearTimeout(lease.timer);
entry.leases.delete(leaseId);
if (!entry.leases.size) stop(cameraId, entry.token);
};
const touch = (entry, leaseId, create = false, clientId = 'legacy') => {
const touch = (entry, leaseId, create = false) => {
let lease = entry.leases.get(leaseId);
if (lease && lease.clientId !== clientId)
throw new Error('HLS lease belongs to another client');
if (!lease) {
const clientLeases = [...active.values()].reduce(
(count, session) =>
count +
[...session.leases.values()].filter(
(item) => item.clientId === clientId,
).length,
0,
);
if (clientLeases >= limits.leasesPerClient)
throw new Error('HLS client lease capacity reached');
if (!create || entry.leases.size >= limits.leasesPerSession)
throw new Error('HLS lease capacity reached');
lease = { clientId };
lease = {};
entry.leases.set(leaseId, lease);
}
clearTimeout(lease.timer);
lease.timer = setTimeout(
() => release(entry.cameraId, leaseId, clientId),
() => release(entry.cameraId, leaseId),
limits.idleMs,
);
lease.timer.unref?.();
@@ -261,12 +248,7 @@ export function createHlsPuller({
}
}
};
const ensure = async (
cameraId,
url,
leaseId = 'legacy',
clientId = 'legacy',
) => {
const ensure = async (cameraId, url, leaseId = 'legacy') => {
if (closed) throw new Error('HLS service closed');
sameOriginHlsUrl(url, url);
let entry = active.get(cameraId);
@@ -275,7 +257,7 @@ export function createHlsPuller({
entry = null;
}
if (entry) {
touch(entry, leaseId, true, clientId);
touch(entry, leaseId, true);
return entry;
}
// Reserve synchronously, before any await: concurrent creation is singleflight.
@@ -297,8 +279,8 @@ export function createHlsPuller({
pendingDiscontinuity: false,
discontinuitiesRemoved: 0,
};
touch(entry, leaseId, true, clientId);
active.set(cameraId, entry);
touch(entry, leaseId, true);
entry.polling = poll(entry);
return entry;
};
@@ -310,14 +292,9 @@ export function createHlsPuller({
}
return false;
};
const buildPlaylist = async (
entry,
cameraId,
leaseId = 'legacy',
clientId = 'legacy',
) => {
const buildPlaylist = async (entry, cameraId, leaseId = 'legacy') => {
if (entry.stopping || entry.segments.size < 2) return null;
touch(entry, leaseId, false, clientId);
touch(entry, leaseId);
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
const lines = [
'#EXTM3U',
@@ -341,22 +318,12 @@ export function createHlsPuller({
}
return lines.join('\n') + '\n';
};
const getSegment = (
cameraId,
token,
seq,
leaseId = 'legacy',
clientId = 'legacy',
) => {
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
const entry = active.get(cameraId);
if (
!entry ||
entry.token !== token ||
entry.leases.get(leaseId)?.clientId !== clientId
)
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
return null;
const body = entry.segments.get(seq)?.body;
if (body) touch(entry, leaseId, false, clientId);
if (body) touch(entry, leaseId);
return body || null;
};
const shutdown = async () => {
+1
View File
@@ -384,6 +384,7 @@ async function readFeed(
// naturally, without trusting a receiver clock that runs ahead of ours.
const records = normalizeDump1090Aircraft(json, receivedAt - ageMs, {
band: feed.band,
feedId: feed.id,
});
return {
status: { ...summary, status, aircraft: records.length, ageMs },
-87
View File
@@ -120,93 +120,6 @@ test('Georgia resolves from camera geography without a geocoder', async () => {
assert.deepEqual(geocodes, []);
});
test('Gold Coast in Australia cannot acquire its West African namesake outline', async () => {
for (const types of [['locality', 'political'], ['natural_feature']]) {
const { resolve, geocodes } = harness({
lat: -28.0167,
lng: 153.4,
label: 'Gold Coast, Australia',
name: 'Gold Coast',
types,
});
const resolved = await resolve('Gold Coast', viewerAt(-28.0167, 153.4));
assert.equal(resolved?.source, 'geocode');
assert.equal(resolved.lat, -28.0167);
assert.equal(resolved.lon, 153.4);
assert.equal(resolved.naturalRegion, undefined);
const outline = await resolved.resolveOutline();
assert.equal(outline?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Gold Coast']);
}
});
test('natural annotation outlines require a geocoded anchor', async () => {
for (const viewer of [AUSTIN, {}, viewerAt(46.5, 10, 248_000)]) {
const miss = harness();
assert.equal(await miss.resolve('the Alps', viewer), null);
assert.ok(miss.geocodes.includes('the Alps'));
}
const anchored = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types: ['natural_feature'],
});
const resolved = await anchored.resolve('the Alps', {});
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.naturalRegion, 'Alps');
assert.ok(outline.ring.length >= 8);
});
test('overlapping natural candidates continue through geocoding without choosing a ring', async () => {
const { resolve, geocodes } = harness({
lat: 45.6336,
lng: -74.86493,
label: 'Canadian Shield',
name: 'Canadian Shield',
types: ['natural_feature'],
});
const resolved = await resolve(
'Canadian Shield',
viewerAt(45.6336, -74.86493),
);
assert.equal(resolved?.source, 'geocode');
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
assert.deepEqual(geocodes, ['Canadian Shield']);
});
test('geocoded natural outlines respect district and around intent', async () => {
for (const extra of [
{ entityKind: 'district' },
{ intent: 'around_the_thing' },
]) {
const { resolve, geocodes } = harness({
lat: 46.5,
lng: 10,
label: 'Alps',
name: 'Alps',
types:
extra.entityKind === 'district' ? ['locality'] : ['natural_feature'],
});
const resolved = await resolve('the Alps', viewerAt(46.5, 10), extra);
assert.ok(geocodes.includes('the Alps'));
assert.equal((await resolved.resolveOutline())?.naturalRegion, undefined);
}
});
test('cancellation while a bundled admin lookup is pending cannot publish an annotation', async () => {
const controller = new AbortController();
const { resolve, geocodes } = harness();
const pending = resolve('the Alps', viewerAt(46.5, 10), {
signal: controller.signal,
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(geocodes, []);
});
test('country asks and geocoded country scopes use the bundled outline', async () => {
const direct = harness();
for (const name of ['Switzerland', 'Iran', 'France', 'Japan']) {
+10 -10
View File
@@ -30,7 +30,6 @@
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { createSegmentedControl } from '../ui/segmentedControl.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import {
claimPointer,
@@ -433,21 +432,22 @@ export function initDrawTool({ viewer, annotations }) {
const shapeButtons = [
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
];
const shapeControl = createSegmentedControl({
buttons: shapeButtons,
valueOf: (button) => button.dataset.shape,
listen,
onChange: (value) => {
shape = normalizeShape(value);
shapeControl.update(shape);
for (const btn of shapeButtons) {
listen(btn, 'click', () => {
shape = normalizeShape(btn.dataset.shape);
for (const other of shapeButtons) {
const on = other === btn;
other.classList.toggle('active', on);
other.setAttribute('aria-checked', String(on));
}
if (active) {
generation += 1;
session = createDrawSession(shape);
cursor = null;
syncPreview();
}
},
});
});
}
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
-106
View File
@@ -44,115 +44,10 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.source, 'natural-earth');
assert.equal(region.error, undefined);
}
});
test('region ring: duplicate natural names need a containing geocoded anchor', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const miss = geocoderFor(null);
assert.equal(
await resolveRegionRingForQuery('Cordillera Oriental', undefined, miss),
null,
);
assert.deepEqual(miss.calls, ['Cordillera Oriental']);
const inside = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['natural_feature'],
});
const region = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
inside,
);
assert.equal(region?.source, 'natural-earth');
assert.equal(region.name, 'Cordillera Oriental');
assert.ok(region.ring.length >= 8);
assert.deepEqual(inside.calls, ['Cordillera Oriental']);
});
test('region ring: outside and overlapping geocoded natural anchors stay unresolved', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const [name, lat, lng] of [
['Cordillera Oriental', 30.27, -97.74],
['Canadian Shield', 45.6336, -74.86493],
]) {
const geocoder = geocoderFor({
lat,
lng,
label: name,
name,
types: ['natural_feature'],
});
assert.equal(
await resolveRegionRingForQuery(name, undefined, geocoder),
null,
);
assert.deepEqual(geocoder.calls, [name]);
}
});
test('region ring: geocoded administrative scope wins over duplicate natural geography', async () => {
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: async (point) => {
lookups.push(point);
return [];
},
}),
});
const geo = geocoderFor({
lat: 5,
lng: -73,
label: 'Cordillera Oriental',
name: 'Cordillera Oriental',
types: ['locality'],
});
const resolved = await resolveRegionRingForQuery(
'Cordillera Oriental',
undefined,
geo,
);
assert.equal(
resolved,
null,
'an unavailable city boundary is not a natural-region outline',
);
assert.deepEqual(lookups, [{ lat: 5, lon: -73 }]);
assert.deepEqual(geo.calls, ['Cordillera Oriental']);
});
test('region ring: cancellation during bundled lookup and geocode never returns a region', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
const controller = new AbortController();
const pending = resolveRegionRingForQuery('the Alps', controller.signal);
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
const later = new AbortController();
await assert.rejects(
resolveRegionRingForQuery('Cordillera Oriental', later.signal, {
async geocode() {
later.abort();
return { place: null };
},
}),
{ name: 'AbortError' },
);
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
@@ -224,7 +119,6 @@ test('region ring: bundled states and counties resolve without a geocoder', asyn
]) {
const region = await resolveRegionRingForQuery(query, undefined, geocoder);
assert.equal(region?.name, name, query);
assert.equal(region.source, 'bundled-admin');
assert.ok(region.ring.length >= 8, `${query} has a real ring`);
}
assert.deepEqual(geocoder.calls, [], 'no geocoding');
+8 -40
View File
@@ -444,7 +444,6 @@ export function createAnnotationResolver({
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
scope === 'country' ||
@@ -470,7 +469,6 @@ export function createAnnotationResolver({
const ne = await lookupNaturalRegionOutline(target, lat, lon).catch(
() => null,
);
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
const neCentroid = ringCentroid(ne.ring);
@@ -2006,8 +2004,8 @@ export function createAnnotationResolver({
/**
* Region ring for ANALYST queries ("how many flights over Texas / the Alps") —
* a name-only entry point that reuses this module's boundary machinery
* without the annotation pipeline. Unique Natural Earth names resolve first
* (offline); duplicate names require a geocoded containment anchor, then
* without the annotation pipeline. Natural Earth pack first (offline,
* instant; largest-area match is correct for a global name-only ask), then
* geocode + admin boundary for states/countries/counties (Tier A disk-cached
* Overpass, so repeat asks are instant). Returns null when the name doesn't
* resolve to a region-like boundary — the analyst engine reports that
@@ -2022,7 +2020,7 @@ export function createAnnotationResolver({
* @param {AbortSignal} [signal]
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>, source:string}|{name:string, ring:null, error:'region-timeout'}|null>}
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
@@ -2039,24 +2037,16 @@ export function createAnnotationResolver({
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
signal?.throwIfAborted();
if (ne?.candidates === 1 && ne.polygons?.length) {
if (ne?.polygons?.length) {
// Largest ring carries the query scope; multi-ring regions (Andes) keep
// their main cordillera — good enough for containment counting.
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3)
return { name: ne.name, ring, source: 'natural-earth' };
if (ring?.length >= 3) return { name: ne.name, ring };
}
// Bundled states/provinces/counties by name (offline); the main part only,
// like the Natural Earth rung above.
const admin = await findAdminArea(q).catch(() => null);
signal?.throwIfAborted();
if (admin)
return {
name: admin.name,
ring: [...admin.ring],
source: 'bundled-admin',
};
if (admin) return { name: admin.name, ring: [...admin.ring] };
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
@@ -2077,26 +2067,8 @@ export function createAnnotationResolver({
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
signal?.throwIfAborted();
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (
scope === 'auto' ||
(scope === 'compound' && geo.types?.includes('natural_feature'))
) {
const natural = await lookupNaturalRegionOutline(
q,
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (natural)
return {
name: natural.name,
ring: natural.ring,
source: 'natural-earth',
};
}
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
if (scope === 'state' || scope === 'county' || scope === 'country') {
const admin = await findAdminAreaAt(
@@ -2105,16 +2077,12 @@ export function createAnnotationResolver({
geo.lon,
scope,
).catch(() => null);
signal?.throwIfAborted();
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
if (admin) return { name: q, ring: [...admin.ring] };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
signal?.throwIfAborted();
if (fp?.ring?.length >= 3)
return { name: q, ring: fp.ring, source: 'remote-boundary' };
if (fp?.ring?.length >= 3) return { name: q, ring: fp.ring };
return null;
}
+2 -14
View File
@@ -881,7 +881,6 @@ export function createAnalystEngine(providers) {
followUp && !spec.scope ? lastResult.distanceOf || null : null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
@@ -891,16 +890,8 @@ export function createAnalystEngine(providers) {
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
if (signal?.aborted || !isCurrent()) return cancelled();
throw error;
}
if (signal?.aborted || !isCurrent()) return cancelled();
const region = await providers.resolveRegionRing(scope.name, signal);
if (!isCurrent()) return cancelled();
if (region?.error === 'region-timeout') {
return {
ok: false,
@@ -919,7 +910,6 @@ export function createAnalystEngine(providers) {
}
resolvedScope = region;
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
} else if (scope.kind === 'radius') {
@@ -1059,7 +1049,6 @@ export function createAnalystEngine(providers) {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
@@ -1092,7 +1081,6 @@ export function createAnalystEngine(providers) {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
};
-303
View File
@@ -1,8 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
// Stub world: a square "Texland" region, flights + ships + fires around it.
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
@@ -133,175 +131,6 @@ test('analyst: count flights over a region', async () => {
assert.ok(r.coverage.scope.includes('Texland'));
});
test('analyst: region coverage identifies the source of the boundary used for counting', async () => {
for (const source of ['natural-earth', 'bundled-admin', 'remote-boundary']) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async () => ({ ...TEXLAND, source }),
});
const result = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, 'the boundary excludes the Oregon flight');
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.coverage.scopeSource, source);
}
});
test('analyst: filter-only follow-ups retain the region source without resolving again', async () => {
const lookups = [];
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
lookups.push(name);
return { ...TEXLAND, source: 'natural-earth' };
},
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const airborne = await engine.query({
followUp: true,
filters: [{ field: 'onGround', op: 'eq', value: false }],
});
const military = await engine.query({
followUp: true,
filters: [{ field: 'military', op: 'eq', value: true }],
});
assert.equal(first.count, 3);
assert.equal(airborne.count, 2);
assert.deepEqual(military.items.map((item) => item.id), ['RCH01']);
for (const result of [first, airborne, military]) {
assert.equal(result.coverage.scope, 'region:Texland');
assert.equal(result.scopeLabel, 'over Texland');
assert.equal(result.coverage.scopeSource, 'natural-earth');
}
assert.deepEqual(lookups, ['Texland']);
});
test('analyst: an explicit replacement region replaces retained provenance', async () => {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: {
name,
ring: [[-98, 30.5], [-96, 30.5], [-96, 31.5], [-98, 31.5]],
source: 'remote-boundary',
},
});
await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({
followUp: true,
scope: { kind: 'region', name: 'Northern Texland' },
});
const followUp = await engine.query({ followUp: true });
for (const result of [replacement, followUp]) {
assert.deepEqual(result.items.map((item) => item.id), ['RCH01']);
assert.equal(result.coverage.scope, 'region:Northern Texland');
assert.equal(result.coverage.scopeSource, 'remote-boundary');
}
});
test('analyst: an explicit scope without provenance clears the retained region source', async () => {
for (const scope of [
{ kind: 'anywhere' },
{ kind: 'view' },
{ kind: 'radius', km: 150 },
{ kind: 'region', name: 'Uncredited Texland' },
]) {
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({
...TEXLAND,
name,
...(name === 'Texland' ? { source: 'natural-earth' } : {}),
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await engine.query({
layers: ['flights'],
scope: { kind: 'region', name: 'Texland' },
});
const replacement = await engine.query({ followUp: true, scope });
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.scopeSource, 'natural-earth');
for (const result of [replacement, followUp]) {
assert.equal(result.ok, true, scope.kind);
assert.equal(result.count, 3, scope.kind);
assert.equal(Object.hasOwn(result.coverage, 'scopeSource'), false, scope.kind);
assert.notEqual(result.coverage.scope, first.coverage.scope, scope.kind);
}
}
});
for (const [label, committedScope, scopeSource] of [
['replacement boundary source', 'region:Presented Texland', 'remote-boundary'],
['window without a boundary source', 'window:presented', null],
]) {
test(`analyst: pre-commit displayed cohort retains ${label} and its feed provenance`, async () => {
const committedSnapshot = {
id: 'flights', enabled: true, feedState: 'stale',
source: 'displayed-cohort-feed', lastUpdate: 1_800_000_000_000,
};
let reads = 0;
let lookups = 0;
const engine = createAnalystEngine({
getRecords: () => { reads++; return FLIGHTS; },
getLayerSnapshot: () => ({
id: 'flights', enabled: true, feedState: 'nominal', source: 'current-feed',
}),
resolveRegionRing: async () => {
lookups++;
return { ...TEXLAND, source: 'natural-earth' };
},
});
await engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{
beforeCommit: async (result) => {
assert.equal(result.count, 3);
assert.equal(result.coverage.scopeSource, 'natural-earth');
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
};
},
},
);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.count, 1);
assert.deepEqual(followUp.items.map((item) => item.id), ['RCH01']);
assert.equal(followUp.scopeLabel, 'the presented cohort');
assert.equal(followUp.display.scope, 'the presented cohort window');
assert.equal(followUp.coverage.scope, committedScope);
if (scopeSource) assert.equal(followUp.coverage.scopeSource, scopeSource);
else assert.equal(Object.hasOwn(followUp.coverage, 'scopeSource'), false);
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
assert.deepEqual(followUp.coverage.feedProvenance.layers, [committedSnapshot]);
assert.equal(reads, 1, 'displayed cohort is not replaced by another feed read');
assert.equal(lookups, 1, 'follow-up retains the committed scope without resolving again');
});
}
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -488,138 +317,6 @@ test('analyst: region lookup receives and obeys the owning action signal', async
assert.equal(result.cancelled, true);
});
for (const [label, reason, trackAuthority] of [
['default abort reason', undefined, false],
['custom error reason', new Error('operator stopped'), false],
['primitive reason and revoked authority', 'operator stopped', true],
]) {
test(`analyst: real region resolver cancellation preserves memory (${label})`, async () => {
const started = Promise.withResolvers();
const geocode = Promise.withResolvers();
const controller = new AbortController();
const lookups = [];
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: Object.fromEntries(FEATURE_SOURCE_METHODS.map((method) => [
method,
async () => { throw new Error('offline region query reached feature source'); },
])),
});
const placeSearch = {
geocode(name, { signal }) {
lookups.push(name);
assert.equal(signal, controller.signal);
started.resolve();
return geocode.promise;
},
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
const first = await engine.query({
layers: ['flights'], scope: { kind: 'region', name: 'Texas' },
});
assert.equal(first.ok, true);
assert.equal(first.coverage.scopeSource, 'bundled-admin');
assert.deepEqual(first.items.map((item) => item.id), ['SWA1', 'RCH01', 'GND1']);
let commits = 0;
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Cordillera Oriental' } },
{
signal: controller.signal,
...(trackAuthority ? { isCurrent: () => !controller.signal.aborted } : {}),
beforeCommit: async () => { commits++; },
},
);
await started.promise;
controller.abort(reason);
// Let an actual late geocode result cross the resolver's post-await check.
geocode.resolve({ place: {
lat: 5, lng: -73, label: 'Cordillera Oriental',
name: 'Cordillera Oriental', types: ['natural_feature'],
} });
const result = await pending;
assert.equal(result.ok, false);
assert.equal(result.code, 'CANCELLED');
assert.equal(result.cancelled, true);
assert.equal(commits, 0);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'bundled-admin');
assert.equal(followUp.scopeLabel, first.scopeLabel);
assert.deepEqual(lookups, ['Cordillera Oriental'], 'follow-up does not resolve again');
});
}
test('analyst: a rejected superseded region lookup returns cancellation and retains its prior cohort', async () => {
const lookup = Promise.withResolvers();
let current = true;
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name) => name === 'Texland'
? { ...TEXLAND, source: 'natural-earth' }
: lookup.promise,
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ isCurrent: () => current },
);
current = false;
lookup.reject(new Error('late lookup failed'));
assert.equal((await pending).code, 'CANCELLED');
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
assert.equal(followUp.coverage.scope, first.coverage.scope);
});
test('analyst: an aborted owner cannot commit a region provider that returns a late success', async () => {
const controller = new AbortController();
const lookup = Promise.withResolvers();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: () => lookup.promise,
});
const pending = engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } },
{ signal: controller.signal },
);
controller.abort();
lookup.resolve({ ...TEXLAND, source: 'natural-earth' });
assert.equal((await pending).code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
for (const error of [new Error('live lookup failed'), new DOMException('resource stopped', 'AbortError')]) {
test(`analyst: live region ${error.name} propagates without changing memory`, async () => {
const controller = new AbortController();
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => {
if (name === 'Texland') return { ...TEXLAND, source: 'natural-earth' };
throw error;
},
});
const first = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
await assert.rejects(
engine.query(
{ layers: ['flights'], scope: { kind: 'region', name: 'Replacement' } },
{ signal: controller.signal },
),
(thrown) => thrown === error,
);
assert.equal(controller.signal.aborted, false);
const followUp = await engine.query({ followUp: true });
assert.deepEqual(followUp.items.map((item) => item.id), first.items.map((item) => item.id));
assert.equal(followUp.coverage.scope, first.coverage.scope);
assert.equal(followUp.coverage.scopeSource, 'natural-earth');
});
}
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
-37
View File
@@ -223,40 +223,3 @@ test('an abandoned consumer expires while a renewed consumer keeps the session',
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
test('client lease ownership covers refresh, segments and release, with a bounded client budget', async (t) => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, leasesPerClient: 1, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
t.after(() => manager.shutdown());
const entry = await manager.ensure('a', base, 'lease-a', 'peer-a');
await manager.waitReady(entry);
await assert.rejects(
manager.ensure('a', base, 'lease-a', 'peer-b'),
/another client/,
);
assert.equal(
manager.getSegment('a', entry.token, 0, 'lease-a', 'peer-b'),
null,
);
await assert.rejects(
manager.buildPlaylist(entry, 'a', 'lease-a', 'peer-b'),
/another client/,
);
manager.release('a', 'lease-a', 'peer-b');
assert.equal(entry.leases.size, 1);
await assert.rejects(
manager.ensure('b', base, 'lease-b', 'peer-a'),
/client lease capacity/,
);
assert.equal(
manager.stats().sessions,
1,
'failed admission reserves no session',
);
await manager.ensure('b', base, 'lease-b', 'peer-b');
manager.release('a', 'lease-a', 'peer-a');
assert.equal(entry.stopping, true);
});
-43
View File
@@ -268,46 +268,3 @@ test('/frame does not follow a still camera off its origin', async (t) => {
[STILL.url],
);
});
test('cross-site media creation, segment reads and release fail before any catalog or media fetch', async (t) => {
let handler;
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls++;
throw new Error('must not fetch');
};
t.after(() => {
globalThis.fetch = originalFetch;
});
cctvProxy().configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
for (const headers of [
{ origin: 'https://foreign.example' },
{ origin: 'null' },
{ 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' },
]) {
for (const [method, url] of [
['GET', '/media/camera?lease=test'],
['DELETE', '/media/camera?lease=test'],
['GET', '/media/camera/seg_0.ts?lease=test'],
]) {
const { res } = recordingResponse();
res.setHeader = (name, value) => {
res.headers[name] = value;
};
await handler(
{ method, url, headers: { host: 'localhost:4173', ...headers } },
res,
);
assert.equal(res.statusCode, 403);
}
}
assert.equal(calls, 0);
});
+36
View File
@@ -180,6 +180,42 @@ test('live feeds are read in parallel and labelled with their band', async () =>
// Ages are anchored at the feed's own `now`.
assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600);
assert.equal(shinr[0].lat, 30.269662);
assert.equal(
shinr[0].altitudeObservedAt,
null,
'feed has no altitude measurement time',
);
assert.equal(
shinr[0].feedSnapshotAt,
FIXTURE_NOW_MS,
'source clock is not rebased',
);
assert.equal(typeof shinr[0].feedId, 'string');
assert.notEqual(
shinr[0].feedId,
shinr[1].feedId,
'each receiver has a distinct opaque id',
);
assert.ok(!JSON.stringify(json).includes(FEED_1090));
assert.ok(!JSON.stringify(json).includes(FEED_978));
});
test('a decoder without a source clock cannot supply inferred altitude progress', async () => {
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090}`,
logger: quietLogger(),
now: () => 1_000,
fetchImpl: async () =>
respond(
200,
JSON.stringify({
aircraft: [{ hex: 'abc123', alt_baro: 35_000, messages: 5 }],
}),
),
});
const { json } = await call(handler);
assert.equal(json.records[0].feedSnapshotAt, null);
assert.equal(json.records[0].altitudeObservedAt, null);
});
test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => {
+21 -28
View File
@@ -189,7 +189,7 @@ const loadIndex = createRetryableLoader(async () => {
return index;
});
function toResult(entry, candidates = 1) {
function toResult(entry) {
return {
name: entry.name,
featurecla: entry.featurecla,
@@ -197,7 +197,6 @@ function toResult(entry, candidates = 1) {
polygons: entry.polygons,
bboxDiagonalKm: entry.bboxDiagonalKm,
areaKm2: entry.areaKm2,
candidates,
};
}
@@ -210,9 +209,7 @@ function toResult(entry, candidates = 1) {
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
* areaKm2:number, candidates:number}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. Uniqueness in
* this pack alone does not establish the user's intended place.
* areaKm2:number}|null>} largest-area match, or null when nothing matches.
*/
export async function findNaturalRegion(query) {
const norm = normalizeName(query);
@@ -226,7 +223,7 @@ export async function findNaturalRegion(query) {
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
if (list && list.length) return toResult(list[0], list.length);
if (list && list.length) return toResult(list[0]);
}
return null;
}
@@ -273,10 +270,9 @@ export function pointInRing(ring, lat, lon) {
/**
* Resolve a natural-region OUTLINE ring for the annotation resolver's first
* rung. Stricter than `findNaturalRegion`: walks ALL entries sharing the
* matched name (duplicate names included), trying exact names, aliases and
* broader suffix variants in that order. Returns a ring only when exactly one
* entry under the matched name CONTAINS the anchor. Containment is the
* disambiguator and the
* matched name (duplicate names included — the US and Spanish "Sierra
* Nevada" both exist upstream) and returns the single ring that CONTAINS the
* geocoded anchor. Containment is simultaneously the disambiguator and the
* wrong-place guard: when no ring contains the anchor this returns null and
* the resolver's normal ladder continues unchanged.
*
@@ -295,26 +291,23 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
ALIASES[norm],
...suffixVariants(ALIASES[norm] || norm),
];
for (const key of new Set(candidates.filter(Boolean))) {
const containing = [];
const seen = new Set();
for (const key of candidates) {
if (!key || seen.has(key)) continue;
seen.add(key);
for (const entry of index.get(key) || []) {
const ring = entry.polygons.find((candidate) =>
pointInRing(candidate, lat, lon),
);
if (ring) containing.push({ entry, ring });
for (const ring of entry.polygons) {
if (pointInRing(ring, lat, lon)) {
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
}
}
// Specific names win over aliases and broader rewrites. Ambiguity belongs
// to this name, not the union of every lower-priority candidate name.
if (containing.length > 1) return null;
if (containing.length === 0) continue;
const [{ entry, ring }] = containing;
return {
name: entry.name,
kind: entry.kind,
featurecla: entry.featurecla,
ring,
areaKm2: entry.areaKm2,
};
}
return null;
}
+2 -41
View File
@@ -33,10 +33,7 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
});
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
const alps = await findNaturalRegion('the Alps');
assert.equal(alps?.name, 'Alps');
assert.equal(alps?.candidates, 1);
assert.equal((await findNaturalRegion('Alps mountains'))?.name, 'Alps');
assert.equal((await findNaturalRegion('the Alps'))?.name, 'Alps');
assert.equal((await findNaturalRegion('Rockies'))?.name, 'Rocky Mountains');
assert.equal((await findNaturalRegion('the Rockies'))?.name, 'Rocky Mountains');
assert.equal((await findNaturalRegion('Sahara Desert'))?.name, 'Sahara');
@@ -44,11 +41,6 @@ test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"',
assert.equal((await findNaturalRegion(' THE ALPS '))?.name, 'Alps');
});
test('duplicate natural-region names expose ambiguity instead of claiming uniqueness', async () => {
assert.ok((await findNaturalRegion('Cordillera Oriental'))?.candidates > 1);
assert.ok((await findNaturalRegion('Canadian Shield'))?.candidates > 1);
});
test('major deserts/ranges resolve with sane areas', async () => {
const sahara = await findNaturalRegion('Sahara');
assert.ok(sahara && sahara.areaKm2 > 8_000_000 && sahara.areaKm2 < 12_000_000,
@@ -130,55 +122,24 @@ test('listRegions() enumerates both kinds for diagnostics', async () => {
});
// ── lookupNaturalRegionOutline (resolver first-rung contract) ──────
test('outline lookup: containing exact names win over broader suffix variants', async () => {
test('outline lookup: Alps ring via containment anchor', async () => {
const r = await lookupNaturalRegionOutline('the Alps', 46.5, 10.0);
assert.ok(r, 'Alps must resolve with an inside anchor');
assert.equal(r.name, 'Alps');
assert.ok(r.ring.length >= 8, 'range-scale ring');
assert.ok(r.areaKm2 > 100000 && r.areaKm2 < 400000, `sane Alps area, got ${r.areaKm2}`);
const range = await lookupNaturalRegionOutline('Alaska Range', 63.14, -148.71);
assert.equal(range?.name, 'Alaska Range');
assert.ok(pointInRing(range.ring, 63.14, -148.71));
assert.equal((await lookupNaturalRegionOutline('Alaska', 63.14, -148.71))?.name, 'Alaska');
});
test('outline lookup: duplicate names disambiguate by anchor containment', async () => {
const us = await lookupNaturalRegionOutline('Sierra Nevada', 37.2, -119.0);
assert.ok(us, 'US anchor must match a Sierra Nevada');
assert.ok(pointInRing(us.ring, 37.2, -119.0), 'returned ring contains the US anchor');
const colombia = await lookupNaturalRegionOutline('Cordillera Oriental', 5, -73);
assert.ok(colombia, 'a single containing candidate resolves a duplicate name');
assert.ok(pointInRing(colombia.ring, 5, -73));
});
test('outline lookup: overlapping duplicate candidates remain unresolved', async () => {
assert.equal(await lookupNaturalRegionOutline('Canadian Shield', 45.6336, -74.86493), null);
});
test('outline lookup: anchor outside every ring → null (wrong-place guard)', async () => {
assert.equal(await lookupNaturalRegionOutline('the Alps', 30.26, -97.77), null);
});
test('outline lookup: a unique physical-region name does not override an outside contemporary place', async () => {
for (const [name, lat, lon] of [
['Gold Coast', -28, 153.4],
['Santa Cruz', 37, -122],
['Isabela', 17, 122],
['India', 28, 95], // Northeast India lies outside the physical peninsula.
['Korea', 33.5, 126.5], // Jeju lies outside the physical peninsula.
]) {
assert.equal((await findNaturalRegion(name))?.candidates, 1, `${name} has a unique physical namesake`);
assert.equal(await lookupNaturalRegionOutline(name, lat, lon), null, `${name} requires containment`);
}
});
test('outline lookup: aliases retain containment checks', async () => {
const alps = await lookupNaturalRegionOutline('Alps mountains', 46.5, 10);
assert.equal(alps?.name, 'Alps');
assert.ok(pointInRing(alps.ring, 46.5, 10));
assert.equal(await lookupNaturalRegionOutline('Alps mountains', -28, 153.4), null);
});
test('outline lookup: non-region names never match', async () => {
assert.equal(await lookupNaturalRegionOutline('Zilker Park', 30.26, -97.77), null);
assert.equal(await lookupNaturalRegionOutline('Texas', 31.0, -99.0), null);
+3 -2
View File
@@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
// Air quality (ECCC AQHI) extends the manifest.
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
// Local ADS-B adds altitude quality, observation time and source field hints.
assert.equal(block.length, 30705, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'f3835c8f5b1732cbd5b45e3eb46baa9a979830be2d23dd1c9888a3e98a35f082',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+1 -1
View File
@@ -29,5 +29,5 @@ test('the first-run checkbox keeps its native visible label', () => {
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
assert.match(parameters, /ariaLabel:\s*t\(metadata\.label\)/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
});
+1 -1
View File
@@ -945,7 +945,7 @@
"Image by {creator}": "Imagen de {creator}",
"zoom in to see KartaView coverage": "acércate para ver la cobertura de KartaView",
"no KartaView photos in view": "no hay fotos de KartaView a la vista",
"(by upload date)": "(por fecha de subida)",
"{names}: by upload date": "{names}: por fecha de subida",
"{n} streets in view · click a line for its nearest photo": {
"one": "{n} calle a la vista · haz clic en una línea para su foto más cercana",
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
+1 -1
View File
@@ -954,7 +954,7 @@
"Image by {creator}": "Автор снимка: {creator}",
"zoom in to see KartaView coverage": "приблизьтесь, чтобы увидеть покрытие KartaView",
"no KartaView photos in view": "фотографий KartaView в зоне видимости нет",
"(by upload date)": "(по дате загрузки)",
"{names}: by upload date": "{names}: по дате загрузки",
"{n} streets in view · click a line for its nearest photo": {
"one": "{n} улица в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
"few": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
+73
View File
@@ -2,6 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
import {
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
} from '../../sources/adsbRecords.js';
const flush = () => new Promise((resolve) => setImmediate(resolve));
@@ -100,6 +104,75 @@ test('configured feeds poll every second between start and stop, on the browser
assert.equal(seen.at(-1), false);
});
test('transport jitter rebases liveness without inventing altitude observations', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const base = 1_000_000;
const report = (altitudeFt, at, messages) =>
aircraft({
altitudeFt,
altitudeObservedAt: null,
feedId: 'feed-1',
feedSnapshotAt: at,
messageCount: messages,
lastPositionAt: at,
lastMessageAt: at,
});
const cachedBad = payload([report(108_800, base + 1_000, 2)], base + 1_000);
const { fetchImpl } = route([
payload([report(35_000, base, 1)], base),
cachedBad,
cachedBad,
cachedBad,
payload([report(108_800, base + 8_000, 3)], base + 8_000),
payload([report(108_800, base + 9_000, 4)], base + 9_000),
]);
let clock = base;
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
t.after(() => feeds.destroy());
const gate = createLocalAdsbAltitudeGate();
feeds.start();
await flush();
gateLocalAdsbAltitude(feeds.getState().records[0], gate, clock);
for (const delay of [20, 740, 5_500]) {
clock = base + 1_000 + delay;
t.mock.timers.tick(1_000);
await flush();
const record = feeds.getState().records[0];
assert.equal(
record.lastMessageAt,
clock,
'transport clock changes on every receipt',
);
assert.equal(
record.feedSnapshotAt,
base + 1_000,
'raw source clock survives rebasing',
);
assert.equal(record.altitudeObservedAt, null);
const held = gateLocalAdsbAltitude(record, gate, clock);
assert.equal(held.altitudeFt, 35_000);
assert.equal(held.altitudeQuality, 'held-after-rejection');
}
assert.equal(
gate.rejected,
1,
'cached value produces one refusal despite >500 ms latency swings',
);
for (const at of [base + 8_000, base + 9_000]) {
clock = at;
t.mock.timers.tick(1_000);
await flush();
const result = gateLocalAdsbAltitude(
feeds.getState().records[0],
gate,
clock,
);
assert.equal(result.altitudeFt, at === base + 8_000 ? 35_000 : 108_800);
if (at === base + 9_000)
assert.equal(result.altitudeQuality, 'inferred-reanchor');
}
});
test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const bodies = [
+30 -5
View File
@@ -10,6 +10,8 @@ import { isPointerFree } from '../../data/inputOwnership.js';
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { routePlausible } from '../../data/routePlausible.js';
import {
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbPositionIsFresh,
mergeLocalAdsbRecords,
summarizeLocalAdsb,
@@ -145,6 +147,7 @@ export function createLocalAdsbLayer({
let models = null;
let geoidReady = false;
let rejectedFixes = 0;
let rejectedAltitudes = 0;
let state = receiver.getState();
const markers = new Map();
// Per-ICAO receptions (band|source -> last message) across merges.
@@ -322,6 +325,13 @@ export function createLocalAdsbLayer({
aircraftClass: marker.evidence ? marker.klass : null,
typeCode: meta?.typeCode ?? null,
altitudeFt: record.altitudeFt,
...(Number.isFinite(record.altitudeFt)
? {
altitudeQuality: record.altitudeQuality,
altitudeObservedAt: record.altitudeObservedAt,
altitudeSource: record.altitudeSource,
}
: {}),
groundSpeedKt: record.groundSpeedKt,
trackDeg: record.trackDeg,
verticalRateFpm: record.verticalRateFpm,
@@ -408,30 +418,36 @@ export function createLocalAdsbLayer({
// ── Markers ─────────────────────────────────────────────────────────────
function upsertMarker(record, at) {
const id = `${ENTITY_PREFIX}${record.icao}`;
function upsertMarker(heard, at) {
const id = `${ENTITY_PREFIX}${heard.icao}`;
let marker = markers.get(id);
if (!marker) {
marker = {
id,
entity: null,
record,
record: heard,
motion: new LocalAdsbMotion(),
altitude: createLocalAdsbAltitudeGate(),
position: null,
lat: null,
lon: null,
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
courseDeg: Number.isFinite(heard.trackDeg) ? heard.trackDeg : null,
lastRotation: 0,
klass: null,
evidence: false,
iconKind: null,
iconScale: null,
onGround: Boolean(record.onGround),
onGround: Boolean(heard.onGround),
geoidN: null,
modelOwnsVisual: false,
};
markers.set(id, marker);
}
// Gated before anything reads the record, so the marker height, the card,
// the 3D-model ceiling and route plausibility agree on one altitude.
const altitudesRejectedBefore = marker.altitude.rejected;
const record = gateLocalAdsbAltitude(heard, marker.altitude, at);
rejectedAltitudes += marker.altitude.rejected - altitudesRejectedBefore;
marker.record = record;
marker.onGround = Boolean(record.onGround);
const rejectedBefore = marker.motion.rejectedFixes;
@@ -810,6 +826,8 @@ export function createLocalAdsbLayer({
// plus the layer's check on every merged record.
rejectedPositions:
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
// Altitude reports refused by the vertical-rate check.
rejectedAltitudes,
...localAdsbStatus({
receiver: current,
feedState: feeds?.getState?.() || null,
@@ -854,6 +872,13 @@ export function createLocalAdsbLayer({
altitudeM: Number.isFinite(record.altitudeFt)
? Math.round(record.altitudeFt * 0.3048)
: null,
...(Number.isFinite(record.altitudeFt)
? {
altitudeQuality: record.altitudeQuality,
altitudeObservedAt: record.altitudeObservedAt,
altitudeSource: record.altitudeSource,
}
: {}),
speedKts: num(record.groundSpeedKt),
heading: num(record.trackDeg),
onGround: record.onGround === true,
+246
View File
@@ -120,6 +120,13 @@ function record(overrides = {}) {
rssiDbfs: null,
band: '1090',
source: 'webusb',
...(overrides.source !== 'feed' && overrides.altitudeFt !== null
? {
altitudeObservedAt: 100_000,
altitudeSequence: 1,
altitudeSessionAt: 100_000,
}
: {}),
...overrides,
};
}
@@ -399,6 +406,245 @@ test('an implausible jump in a feed record is ignored by the display', async (t)
assert.equal(layer.getStats().rejectedPositions, 1);
});
test('a corrupt altitude reaches neither the marker height nor the card', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources, calls } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
const entity = () => sources[0].entities.getById('local-adsb:abc123');
const heightM = () =>
Cesium.Cartographic.fromCartesian(entity().position.getValue()).height;
const altitudeLine = () =>
entity().gevLabelModel.details.find((line) => line.startsWith('ALT '));
const provenance = () => {
const { altitudeQuality, altitudeObservedAt, altitudeSource } =
calls.registered.at(-1).properties;
return { altitudeQuality, altitudeObservedAt, altitudeSource };
};
receiver.set({ aircraft: [record()] });
await layer.update();
const cruiseM = heightM();
assert.match(altitudeLine(), /^ALT 10,000 FT/);
assert.deepEqual(provenance(), {
altitudeQuality: 'accepted',
altitudeObservedAt: 100_000,
altitudeSource: 'webusb',
});
clock.now = 101_000;
receiver.set({
aircraft: [
record({
altitudeFt: 108_800,
altitudeObservedAt: 101_000,
altitudeSequence: 2,
lastPositionAt: 101_000,
lastMessageAt: 101_000,
}),
],
});
await layer.update();
await layer.update();
assert.ok(
Math.abs(heightM() - cruiseM) < 1,
`held at cruise (${heightM()} m vs ${cruiseM} m)`,
);
assert.match(altitudeLine(), /^ALT 10,000 FT/);
assert.equal(layer.getStats().rejectedAltitudes, 1, 'counted once');
assert.deepEqual(provenance(), {
altitudeQuality: 'held-after-rejection',
altitudeObservedAt: 100_000,
altitudeSource: 'webusb',
});
// New callsign/velocity messages retain the refused altitude observation.
for (const messageAt of [101_250, 101_500]) {
clock.now = messageAt;
receiver.set({
aircraft: [
record({
altitudeFt: 108_800,
altitudeObservedAt: 101_000,
altitudeSequence: 2,
lastPositionAt: 101_000,
lastMessageAt: messageAt,
messageCount: 13,
}),
],
});
await layer.update();
assert.match(altitudeLine(), /^ALT 10,000 FT/);
assert.ok(Math.abs(heightM() - cruiseM) < 1);
assert.equal(layer.getStats().rejectedAltitudes, 1);
}
clock.now = 102_000;
receiver.set({
aircraft: [
record({
altitudeFt: 10_100,
altitudeObservedAt: 102_000,
altitudeSequence: 3,
lastPositionAt: 102_000,
lastMessageAt: 102_000,
}),
],
});
await layer.update();
assert.match(altitudeLine(), /^ALT 10,100 FT/);
assert.equal(layer.getStats().rejectedAltitudes, 1);
assert.deepEqual(provenance(), {
altitudeQuality: 'accepted',
altitudeObservedAt: 102_000,
altitudeSource: 'webusb',
});
assert.equal(layer.getAnalystRecords()[0].altitudeQuality, 'accepted');
assert.equal(layer.getAnalystRecords()[0].altitudeObservedAt, 102_000);
});
test('held altitude keeps its accepted feed provenance until a browser correction', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, calls } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
const provenance = (value) => ({
altitudeQuality: value.altitudeQuality,
altitudeObservedAt: value.altitudeObservedAt,
altitudeSource: value.altitudeSource,
});
receiver.set({ aircraft: [record({ source: 'feed' })] });
await layer.update();
const estimate = {
altitudeQuality: 'feed-estimate',
altitudeObservedAt: null,
altitudeSource: 'feed',
};
assert.deepEqual(provenance(calls.registered.at(-1).properties), estimate);
assert.deepEqual(provenance(layer.getAnalystRecords()[0]), estimate);
clock.now = 101_000;
receiver.set({
aircraft: [
record({
altitudeFt: 108_800,
altitudeObservedAt: 101_000,
altitudeSequence: 2,
lastPositionAt: clock.now,
lastMessageAt: clock.now,
}),
],
});
await layer.update();
const held = { ...estimate, altitudeQuality: 'held-after-rejection' };
assert.deepEqual(provenance(calls.registered.at(-1).properties), held);
const [row] = layer.getAnalystRecords();
assert.equal(row.altitudeM, 3_048);
assert.deepEqual(provenance(row), held);
clock.now = 102_000;
receiver.set({
aircraft: [
record({
altitudeFt: 10_100,
altitudeObservedAt: clock.now,
altitudeSequence: 3,
lastPositionAt: clock.now,
lastMessageAt: clock.now,
}),
],
});
await layer.update();
const accepted = {
altitudeQuality: 'accepted',
altitudeObservedAt: clock.now,
altitudeSource: 'webusb',
};
assert.deepEqual(provenance(calls.registered.at(-1).properties), accepted);
assert.deepEqual(provenance(layer.getAnalystRecords()[0]), accepted);
});
test('missing altitude publishes no quality claim in context or analyst records', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, calls } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
receiver.set({ aircraft: [record({ altitudeFt: null })] });
await layer.update();
const [row] = layer.getAnalystRecords();
assert.equal(row.altitudeM, null);
for (const value of [row, calls.registered.at(-1).properties]) {
for (const key of [
'altitudeQuality',
'altitudeObservedAt',
'altitudeSource',
])
assert.equal(Object.hasOwn(value, key), false, key);
}
});
test('feed recovery uses the layer clock and publishes an inferred altitude', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const feeds = fakeFeeds({ configured: true });
const { layer, calls, sources } = await enabledLayer(receiver, clock, feeds);
t.after(() => layer.destroy());
const heard = (altitudeFt, snapshotAt, messageAt, messageCount) =>
record({
source: 'feed',
altitudeFt,
altitudeObservedAt: null,
feedId: 'feed-1',
feedSnapshotAt: snapshotAt,
lastPositionAt: snapshotAt,
lastMessageAt: messageAt,
messageCount,
});
feeds.set({ records: [heard(108_800, clock.now, 99_000, 12)] });
await layer.update();
assert.equal(layer.getAnalystRecords()[0].altitudeQuality, 'feed-estimate');
// Source message times advance slowly; the five-second recovery uses the
// injected layer clock, not those message times or real wall-clock time.
for (const [at, messageAt, messageCount] of [
[101_000, 100_000, 13],
[103_000, 100_500, 14],
]) {
clock.now = at;
feeds.set({ records: [heard(10_000, at, messageAt, messageCount)] });
await layer.update();
assert.equal(layer.getAnalystRecords()[0].altitudeM, 33_162);
assert.equal(
layer.getAnalystRecords()[0].altitudeQuality,
'held-after-rejection',
);
}
clock.now = 106_000;
feeds.set({ records: [heard(10_000, clock.now, 101_000, 15)] });
await layer.update();
const [row] = layer.getAnalystRecords();
assert.equal(row.altitudeM, 3_048);
assert.equal(row.altitudeQuality, 'inferred-reanchor');
assert.equal(row.altitudeObservedAt, null);
assert.equal(row.altitudeSource, 'feed');
const properties = calls.registered.at(-1).properties;
assert.equal(properties.altitudeQuality, row.altitudeQuality);
assert.equal(properties.altitudeObservedAt, null);
assert.equal(properties.altitudeSource, 'feed');
assert.equal(layer.getStats().rejectedAltitudes, 1);
clock.now = 107_000;
await layer.update();
const entity = sources[0].entities.getById('local-adsb:abc123');
assert.match(
entity.gevLabelModel.details.find((line) => line.startsWith('ALT ')),
/^ALT 10,000 FT/,
);
const heightM = Cesium.Cartographic.fromCartesian(
entity.position.getValue(),
).height;
assert.ok(Math.abs(heightM - 3_048) < 1, `recovered height ${heightM}`);
});
test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
@@ -141,7 +141,7 @@ export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = '(by upload date)';
export const SINCE_NOTE = 'KartaView: by upload date';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
+1 -1
View File
@@ -23,7 +23,7 @@ export function filtersOf(provider) {
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' notes "(by upload date)" beside the SINCE value while the provider is lit.
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
+7 -5
View File
@@ -73,14 +73,16 @@ export function filterControls(providers) {
}
/**
* The note beside the SINCE value when a lit provider dates its imagery by
* upload, "(by upload date)", or null. One source is lit at a time, so the
* note needs no provider name.
* The note under SINCE when a lit provider dates its imagery by upload,
* e.g. "KartaView: by upload date", or null.
* @param {Array<ProviderSnapshot>} providers
*/
export function sinceNote(providers) {
return providers.some((p) => p.on && p.sinceBasis === 'upload')
? t('(by upload date)')
const names = providers
.filter((p) => p.on && p.sinceBasis === 'upload')
.map((p) => p.name);
return names.length
? t('{names}: by upload date', { names: names.join(', ') })
: null;
}
+2 -2
View File
@@ -67,7 +67,7 @@ test('the filter controls show only for a lit provider that honours them', () =>
);
});
test('a lit provider that dates by upload notes it beside the SINCE value', () => {
test('a lit provider that dates by upload puts a note under SINCE', () => {
const kartaView = provider({
id: 'kartaview',
name: 'KartaView',
@@ -75,7 +75,7 @@ test('a lit provider that dates by upload notes it beside the SINCE value', () =
});
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
assert.equal(note([provider()]), null, 'Mapillary: capture date');
assert.equal(note([kartaView]), '(by upload date)');
assert.equal(note([kartaView]), 'KartaView: by upload date');
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
});
-118
View File
@@ -152,124 +152,6 @@ test('regionFramingPlan: E-W elongated region looks along the east axis', () =>
assert.equal(plan.headingDeg, 90);
});
// Compatibility guard for inherited geocode authority, not a newly fixed bug.
test('search retains main geocode authority for Gold Coast, Australia', async () => {
const viewer = stubViewer();
const result = await runSearch(viewer, {}, {
query: 'Gold Coast',
result: {
formatted_address: 'Gold Coast, Queensland, Australia',
types: ['locality', 'political'],
geometry: {
location: { lat: -28, lng: 153.4 },
viewport: boxOf(-28.2, 153.2, -27.8, 153.6),
},
},
});
assert.equal(result.label, 'Gold Coast, Queensland, Australia');
assert.equal(result.navigationMode, 'city-overview');
assert.equal(viewer.flights.length, 1);
const flown = flownRectangleDegrees(viewer);
assert.ok(Math.abs((flown.south + flown.north) / 2 + 28) < 0.000001);
assert.ok(Math.abs((flown.west + flown.east) / 2 - 153.4) < 0.000001);
});
function alpsViewer() {
const viewer = stubViewer();
Object.assign(viewer.camera.positionCartographic, {
latitude: Cesium.Math.toRadians(46.5),
longitude: Cesium.Math.toRadians(10),
height: 248_000,
});
return viewer;
}
test('a successful nearby recovery resolves after a geocoder miss', async () => {
const viewer = alpsViewer();
const result = await searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { return { place: null, answered: true }; } },
recoverNearView: async () => ({
lat: 46.6,
lon: 10.1,
label: 'The Alps local landmark',
types: ['locality', 'political'],
viewport: { low: { latitude: 46.5, longitude: 10 }, high: { latitude: 46.7, longitude: 10.2 } },
}),
});
assert.equal(result.label, 'The Alps local landmark');
assert.equal(result.navigationMode, 'city-overview');
assert.equal(viewer.flights.length, 1);
});
test('a geocoder miss returns null even over a namesake region', async () => {
for (const answered of [true, false]) {
const viewer = alpsViewer();
const result = await searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { return { place: null, answered }; } },
recoverNearView: async () => null,
});
assert.equal(result, null);
assert.equal(viewer.flights.length, 0);
}
});
test('a geocoder rejection propagates without nearby recovery or a flight', async () => {
const viewer = alpsViewer();
const failure = new Error('geocoder failed');
let recoveries = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
placeSearch: { async geocode() { throw failure; } },
recoverNearView: async () => { recoveries += 1; return null; },
}), (error) => error === failure);
assert.equal(recoveries, 0);
assert.equal(viewer.flights.length, 0);
});
test('search cancellation before or during geocoding cannot issue a flight', async () => {
for (const preAborted of [true, false]) {
const viewer = alpsViewer();
const controller = new AbortController();
if (preAborted) controller.abort();
let geocodes = 0;
let recoveries = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
signal: controller.signal,
placeSearch: {
async geocode(_query, { signal }) {
geocodes += 1;
assert.equal(signal, controller.signal);
controller.abort();
return { place: null, answered: true };
},
},
recoverNearView: async () => { recoveries += 1; return null; },
}), { name: 'AbortError' });
assert.equal(geocodes, preAborted ? 0 : 1);
assert.equal(recoveries, 0);
assert.equal(viewer.flights.length, 0);
}
});
test('cancellation during nearby recovery cannot issue a flight', async () => {
const viewer = alpsViewer();
const controller = new AbortController();
let authorityChecks = 0;
await assert.rejects(searchAndFlyTo(viewer, 'the Alps', {
signal: controller.signal,
placeSearch: { async geocode() { return { place: null, answered: true }; } },
recoverNearView: async () => {
controller.abort();
return {
lat: 46.6, lon: 10.1, label: 'Alpine landmark',
types: ['locality'],
};
},
beforeFly: () => { authorityChecks += 1; return true; },
}), { name: 'AbortError' });
assert.equal(authorityChecks, 0);
assert.equal(viewer.flights.length, 0);
});
test('regionFramingPlan: park- and lake-scale viewports keep full framing', () => {
// Zilker Park (~3 km).
assert.equal(regionFramingPlan({
+24
View File
@@ -314,6 +314,10 @@ export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) {
return message;
}
function finiteTime(value) {
return Number.isFinite(value) ? value : null;
}
function recentReference(track, receivedAt) {
if (
!Number.isFinite(track.latitude) ||
@@ -371,6 +375,16 @@ function surfaceReference(track, message) {
* `positionsRejected` on the optional `stats`) and not applied. From the
* third consecutive rejection on, a globally decoded fix is accepted as the
* new reference, so one bad accepted fix cannot freeze a track.
*
* `lastSeen` advances on every message, so it says nothing about how fresh
* `altitudeFt` is: a velocity or identification frame leaves the altitude
* from the last airborne position. Only a frame that carries a decoded
* altitude (TC 9–18 with Q=1) sets `lastAltitudeAt` to its receive time and
* increments `altitudeSequence`, an equal value included. The sequence tells
* apart frames of one USB block, which share a receive time. A surface frame
* clears the altitude and `lastAltitudeAt` and keeps the sequence, so the
* next airborne altitude still counts as new. `firstSeenAt` is when this
* track started; a pruned and re-heard aircraft starts a new track.
* @param {Map<string, object>} tracks Tracks keyed by uppercase ICAO.
* @param {object} message Output of `decodeAdsbMessage`.
* @param {{latitude:number, longitude:number}|null} [receiverLocation]
@@ -396,7 +410,10 @@ export function updateAircraftTrack(
verticalRateFpm: null,
messages: 0,
lastSeen: 0,
firstSeenAt: finiteTime(message.receivedAt),
lastPositionAt: null,
lastAltitudeAt: null,
altitudeSequence: 0,
onGround: false,
cprEven: null,
cprOdd: null,
@@ -428,8 +445,15 @@ export function updateAircraftTrack(
if (message.onGround) {
next.altitudeFt = null;
next.verticalRateFpm = null;
next.lastAltitudeAt = null;
}
}
// An altitude observation, not merely a message. A receive time that is not
// a number is unknown rather than the previous altitude's time.
if (Number.isFinite(message.altitudeFt) && message.onGround !== true) {
next.lastAltitudeAt = finiteTime(message.receivedAt);
next.altitudeSequence = (prior.altitudeSequence || 0) + 1;
}
if (message.cpr) {
const surface = Boolean(message.cpr.surface);
// Airborne and surface frames use different zone sizes: never pair them.
+181
View File
@@ -622,3 +622,184 @@ test('a far receiver never places a lone surface frame; the next valid pair seed
assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`);
assert.equal(track.onGround, true);
});
// Altitude freshness. Frames are real DF17 layouts with recomputed parity:
// the 40621D airborne position pair (38,000 ft) with its 12-bit altitude
// field rewritten, and the KLM1023 identification and 485020 velocity ME
// fields under another address.
const IDENT_ME = '202CC371C32CE0';
const VELOCITY_ME = '99440994083817';
/** A CRC-valid DF17 frame from a 24-bit address and a 56-bit ME, in hex. */
function frame(icao, me) {
return withParity(`8D${icao}${me}000000`);
}
/** Airborne position ME carrying `altitudeFt` in 25 ft (Q=1) steps, or Q=0. */
function airborneMe(altitudeFt, { odd = false, gillham = false } = {}) {
const me = fromHex(odd ? '58C386435CC412' : '58C382D690C8AC');
const n = (altitudeFt + 1_000) / 25;
const field = ((n >> 4) << 5) | (gillham ? 0 : 0x10) | (n & 0xf);
me[1] = field >> 4;
me[2] = ((field & 0xf) << 4) | (me[2] & 0x0f);
return [...me].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function altitudeObservation(track) {
return {
altitudeFt: track.altitudeFt,
lastAltitudeAt: track.lastAltitudeAt,
altitudeSequence: track.altitudeSequence,
};
}
function trackHeard(tracks) {
return (bytes, receivedAt) =>
updateAircraftTrack(tracks, decodeAdsbMessage(bytes, { receivedAt }));
}
test('the altitude frame builder reproduces the published 38,000 ft position', () => {
assert.equal(
decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7')).altitudeFt,
38_000,
);
assert.deepEqual(
frame('40621D', airborneMe(38_000)),
fromHex('8D40621D58C382D690C8AC2863A7'),
);
assert.equal(
decodeAdsbMessage(frame('40621D', airborneMe(36_025))).altitudeFt,
36_025,
);
});
test('only a frame that carries an altitude advances the altitude observation', () => {
const tracks = new Map();
const heard = trackHeard(tracks);
let track = heard(frame('40621D', IDENT_ME), 1_000);
assert.equal(track.callsign, 'KLM1023');
assert.equal(track.firstSeenAt, 1_000);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: null,
lastAltitudeAt: null,
altitudeSequence: 0,
});
heard(frame('40621D', VELOCITY_ME), 1_200);
track = heard(frame('40621D', airborneMe(38_000)), 1_500);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_000,
lastAltitudeAt: 1_500,
altitudeSequence: 1,
});
const cruising = track;
// The next position is missed: velocity and identification frames keep the
// aircraft heard, and say nothing about its altitude.
track = heard(frame('40621D', VELOCITY_ME), 1_700);
assert.equal(track.verticalRateFpm, -832);
track = heard(frame('40621D', IDENT_ME), 1_800);
assert.equal(track.lastSeen, 1_800);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_000,
lastAltitudeAt: 1_500,
altitudeSequence: 1,
});
// Level flight repeats the altitude: still a new observation.
track = heard(frame('40621D', airborneMe(38_000, { odd: true })), 2_000);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_000,
lastAltitudeAt: 2_000,
altitudeSequence: 2,
});
assert.equal(track.firstSeenAt, 1_000, 'the track start survives every copy');
assert.equal(cruising.altitudeSequence, 1, 'an earlier track is not mutated');
});
test('altitude frames of one USB block share a receive time and each count', () => {
// The worker stamps every frame of a block with one Date.now().
const tracks = new Map();
const heard = trackHeard(tracks);
heard(frame('40621D', airborneMe(38_000)), 5_000);
heard(frame('40621D', VELOCITY_ME), 5_000);
heard(frame('40621D', airborneMe(38_000, { odd: true })), 5_000);
const track = heard(frame('40621D', airborneMe(38_025)), 5_000);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_025,
lastAltitudeAt: 5_000,
altitudeSequence: 3,
});
assert.equal(track.messages, 4);
});
test('Q=0 and surface frames never advance the altitude; the next airborne one does', () => {
const tracks = new Map();
const heard = trackHeard(tracks);
heard(frame('C8200A', airborneMe(3_000)), 0);
const gillham = frame('C8200A', airborneMe(3_000, { gillham: true }));
assert.equal(decodeAdsbMessage(gillham).altitudeFt, null, 'Q=0 not decoded');
let track = heard(gillham, 500);
assert.equal(track.onGround, false);
assert.deepEqual(
altitudeObservation(track),
{ altitudeFt: 3_000, lastAltitudeAt: 0, altitudeSequence: 1 },
'the altitude shown is still the one heard at 0',
);
heard(SURFACE_EVEN, 1_000);
track = heard(SURFACE_ODD, 2_000);
assert.equal(track.onGround, true);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: null,
lastAltitudeAt: null,
altitudeSequence: 1,
});
track = heard(frame('C8200A', airborneMe(3_100)), 3_000);
assert.equal(track.onGround, false);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 3_100,
lastAltitudeAt: 3_000,
altitudeSequence: 2,
});
assert.equal(track.firstSeenAt, 0);
});
test('a pruned aircraft heard again starts a new altitude session', () => {
const tracks = new Map();
const heard = trackHeard(tracks);
heard(frame('40621D', airborneMe(38_000)), 1_000);
assert.equal(pruneAircraftTracks(tracks, 61_000), 1);
let track = heard(frame('40621D', VELOCITY_ME), 70_000);
assert.equal(track.firstSeenAt, 70_000);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: null,
lastAltitudeAt: null,
altitudeSequence: 0,
});
track = heard(frame('40621D', airborneMe(38_000)), 70_500);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_000,
lastAltitudeAt: 70_500,
altitudeSequence: 1,
});
});
test('an altitude with an unusable receive time is counted without a time', () => {
const tracks = new Map();
const heard = trackHeard(tracks);
let track = heard(frame('40621D', airborneMe(38_000)), Number.NaN);
assert.equal(track.firstSeenAt, null);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_000,
lastAltitudeAt: null,
altitudeSequence: 1,
});
track = heard(frame('40621D', airborneMe(38_025, { odd: true })), 2_000);
assert.deepEqual(altitudeObservation(track), {
altitudeFt: 38_025,
lastAltitudeAt: 2_000,
altitudeSequence: 2,
});
assert.equal(track.firstSeenAt, null, 'the track start stays unknown');
});
+307 -1
View File
@@ -12,6 +12,11 @@
* lat: number|null, // degrees, null until a position decodes
* lon: number|null,
* altitudeFt: number|null, // barometric altitude
* altitudeObservedAt: number|null, // browser altitude measurement time; unknown for feeds
* altitudeSequence: number|null, // browser altitude-frame counter
* altitudeSessionAt: number|null, // browser track's first message time
* feedId: string|null, // opaque configured receiver id, never its URL
* feedSnapshotAt: number|null, // feed's own clock; never browser-rebased
* groundSpeedKt: number|null,
* trackDeg: number|null, // true track over ground, [0, 360)
* verticalRateFpm: number|null,
@@ -25,6 +30,7 @@
* rssiDbfs: number|null,
* band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT
* source: 'webusb'|'feed', // browser SDR or decoder feed
* // The layer's altitude gate adds altitudeQuality and altitudeSource.
* }
*
* This module is portable: no Cesium, DOM, network or storage access.
@@ -54,6 +60,22 @@ export const LOCAL_ADSB_POSITION_MARGIN_M = 500;
* reference: the previously accepted fix was the outlier, not the stream.
*/
export const LOCAL_ADSB_REANCHOR_AFTER = 3;
/**
* Altitude change always allowed between two reports: Gillham altitudes step
* in 100 ft and barometric readings jitter.
*/
export const LOCAL_ADSB_ALTITUDE_MARGIN_FT = 300;
/** Vertical-rate limit (ft/min) for an aircraft that has not reported one. */
export const LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM = 12_500;
/** Reported vertical rate is multiplied by this, plus the margin below. */
export const LOCAL_ADSB_VERTICAL_RATE_FACTOR = 1.5;
export const LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM = 2_000;
/** Minimum coherent feed recovery window; this is an inference, not a measurement. */
export const LOCAL_ADSB_FEED_RECOVERY_MS = 5_000;
/** Advancing feed snapshots with aircraft activity required for inferred recovery. */
export const LOCAL_ADSB_FEED_RECOVERY_SNAPSHOTS = 3;
/** Bound an unknown feed measurement age without imposing an altitude ceiling. */
export const LOCAL_ADSB_FEED_ALTITUDE_GAP_MS = 60_000;
const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']);
const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8;
@@ -164,6 +186,278 @@ export function localAdsbFixIsPlausible(
);
}
/**
* The fastest an aircraft is assumed to climb or descend, in ft/min: 1.5 × the
* larger reported vertical rate + 2,000 ft/min, or 12,500 ft/min when neither
* report carries one.
* @param {...(number|null|undefined)} rates Reported vertical rates (ft/min).
* @returns {number}
*/
export function localAdsbVerticalRateLimitFpm(...rates) {
const known = rates.filter(Number.isFinite).map(Math.abs);
if (!known.length) return LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM;
return (
Math.max(...known) * LOCAL_ADSB_VERTICAL_RATE_FACTOR +
LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM
);
}
/**
* The altitude counterpart of {@link localAdsbFixIsPlausible}: whether `next`
* is reachable from the previous accepted altitude at a plausible vertical
* rate. A corrupt frame can still pass CRC, and a decoder feed can relay one,
* so a single report of a DC-9 at 108,800 ft has to be refused here. The
* allowance is a fixed margin plus the climb covered in the elapsed time + 1 s
* at {@link localAdsbVerticalRateLimitFpm}. Without a reference every altitude
* is plausible. There is no reference age limit, unlike positions: the
* allowance grows with the gap anyway, 125,000 ft over 10 minutes at the
* unknown rate.
* @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}|null}
* previous Last accepted altitude.
* @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}} next
* Candidate altitude.
* @returns {boolean}
*/
export function localAdsbAltitudeIsPlausible(previous, next) {
if (
!Number.isFinite(previous?.altitudeFt) ||
!Number.isFinite(previous?.at) ||
!Number.isFinite(next?.altitudeFt) ||
!Number.isFinite(next?.at)
)
return true;
const elapsedMs = Math.max(0, next.at - previous.at);
const limitFpm = localAdsbVerticalRateLimitFpm(
previous.verticalRateFpm,
next.verticalRateFpm,
);
const allowedFt =
LOCAL_ADSB_ALTITUDE_MARGIN_FT + ((elapsedMs + 1_000) / 60_000) * limitFpm;
return Math.abs(next.altitudeFt - previous.altitudeFt) <= allowedFt;
}
/**
* State for {@link gateLocalAdsbAltitude}, one per aircraft, owned by the
* caller and dropped with the aircraft.
* @returns {{accepted:object|null, sources:Map,
* rejectStreak:number, rejected:number}}
*/
export function createLocalAdsbAltitudeGate() {
return {
accepted: null,
sources: new Map(),
rejectStreak: 0,
rejected: 0,
};
}
function altitudeResult(record, accepted = null) {
const held = accepted && accepted.altitudeFt !== record.altitudeFt;
return {
...record,
...(accepted ? { altitudeFt: accepted.altitudeFt } : {}),
altitudeQuality: held
? 'held-after-rejection'
: (accepted?.quality ?? null),
altitudeObservedAt: accepted?.observedAt ?? null,
altitudeSource: accepted?.source ?? null,
};
}
function feedAltitudeIsPlausible(previous, next) {
// A retained value can outlive its original measurement indefinitely while
// positions stay fresh. Do not let that accumulate an unlimited allowance.
const bounded = previous
? {
...previous,
at: Math.max(previous.at, next.at - LOCAL_ADSB_FEED_ALTITUDE_GAP_MS),
}
: null;
return localAdsbAltitudeIsPlausible(bounded, next);
}
function altitudeSourceState(gate, key, at) {
for (const [sourceKey, state] of gate.sources) {
if (at - state.lastSeenAt > LOCAL_ADSB_MESSAGE_STALE_MS)
gate.sources.delete(sourceKey);
}
// Each receiver keeps its own votes and watermarks even when the freshest
// position alternates between inputs. Votes from different receivers never mix.
let state = gate.sources.get(key);
if (!state) {
state = {
value: null,
observedAt: null,
sequence: null,
snapshotAt: null,
messageCount: null,
rejectStreak: 0,
pending: null,
};
gate.sources.set(key, state);
}
state.lastSeenAt = at;
gate.rejectStreak = state.rejectStreak;
return state;
}
function acceptAltitude(gate, candidate) {
gate.accepted = candidate;
// Every pending refusal was measured against the previous reference.
for (const state of gate.sources.values()) {
state.pending = null;
state.rejectStreak = 0;
}
gate.rejectStreak = 0;
}
function judgeBrowserAltitude(record, gate, state, candidate) {
const observedAt = finiteOrNull(record.altitudeObservedAt);
const sequence = Number.isSafeInteger(record.altitudeSequence)
? record.altitudeSequence
: null;
if (observedAt === null) {
if (!gate.accepted) acceptAltitude(gate, candidate);
return;
}
if (sequence !== null && state.sequence !== null) {
if (sequence === state.sequence) return;
if (sequence < state.sequence) {
if (observedAt <= state.observedAt) return;
state.rejectStreak = 0;
}
} else if (state.observedAt !== null && observedAt <= state.observedAt)
return;
state.observedAt = observedAt;
state.sequence = sequence;
candidate.at = observedAt;
candidate.observedAt = observedAt;
if (localAdsbAltitudeIsPlausible(gate.accepted, candidate)) {
acceptAltitude(gate, candidate);
} else {
gate.rejected += 1;
state.rejectStreak += 1;
// A sequence gap is still only one observed report, not multiple votes.
if (state.rejectStreak >= LOCAL_ADSB_REANCHOR_AFTER) {
acceptAltitude(gate, candidate);
}
}
gate.rejectStreak = state.rejectStreak;
}
function judgeFeedAltitude(record, gate, state, candidate) {
const snapshotAt = finiteOrNull(record.feedSnapshotAt);
const messageCount = Number.isSafeInteger(record.messageCount)
? record.messageCount
: null;
const hasProgress =
typeof record.feedId === 'string' &&
record.feedId.length > 0 &&
snapshotAt !== null &&
messageCount !== null;
if (
hasProgress &&
((state.snapshotAt !== null && snapshotAt < state.snapshotAt) ||
(state.messageCount !== null && messageCount < state.messageCount))
) {
// A decoder restart or wall-clock adjustment begins a new progress window.
// Its first value is still checked against the accepted altitude.
state.pending = null;
state.value = null;
state.snapshotAt = null;
state.messageCount = null;
}
const progressed =
hasProgress &&
(state.snapshotAt === null ||
(snapshotAt > state.snapshotAt && messageCount > state.messageCount));
if (hasProgress) {
state.snapshotAt = Math.max(state.snapshotAt ?? snapshotAt, snapshotAt);
state.messageCount = Math.max(
state.messageCount ?? messageCount,
messageCount,
);
} else state.pending = null;
const changed = state.value !== candidate.altitudeFt;
state.value = candidate.altitudeFt;
if (changed) {
if (feedAltitudeIsPlausible(gate.accepted, candidate)) {
acceptAltitude(gate, candidate);
return;
}
gate.rejected += 1;
}
if (!gate.accepted || candidate.altitudeFt === gate.accepted.altitudeFt)
return;
// Feed JSON has no altitude-specific age. Repeated snapshots are not new
// refusals. Activity can only support the explicitly inferred recovery below.
if (!hasProgress) return;
if (
!state.pending ||
(changed && !feedAltitudeIsPlausible(state.pending.last, candidate))
) {
state.pending = { startedAt: candidate.at, snapshots: 0, last: candidate };
}
if (changed) state.pending.last = candidate;
if (progressed) state.pending.snapshots += 1;
if (
progressed &&
state.pending.snapshots >= LOCAL_ADSB_FEED_RECOVERY_SNAPSHOTS &&
candidate.at - state.pending.startedAt >= LOCAL_ADSB_FEED_RECOVERY_MS
) {
acceptAltitude(gate, { ...candidate, quality: 'inferred-reanchor' });
}
}
/**
* Hold rejected altitudes and retain the accepted value's provenance.
* Browser votes come only from actual altitude frames. Feeds cannot identify
* an altitude measurement: changed values are judged once, and coherent source
* activity can recover after 5 s / 3 advancing snapshots, explicitly inferred.
* A stable corrupt feed can also satisfy that policy. Missing progression
* metadata disables automatic feed recovery; there is no absolute ceiling.
* @param {object} record Local ADS-B record (never mutated).
* @param {ReturnType<typeof createLocalAdsbAltitudeGate>} gate Caller-owned state.
* @param {number} [nowMs=record.lastMessageAt] Browser clock, injected by the layer.
* @returns {object} Record with altitudeQuality, altitudeObservedAt and altitudeSource.
*/
export function gateLocalAdsbAltitude(
record,
gate,
nowMs = record?.lastMessageAt,
) {
if (record?.onGround) {
gate.accepted = null;
gate.sources.clear();
gate.rejectStreak = 0;
return altitudeResult(record);
}
const altitudeFt = finiteOrNull(record?.altitudeFt);
const at = finiteOrNull(nowMs);
if (altitudeFt === null || at === null) {
for (const state of gate.sources.values()) state.pending = null;
return altitudeResult(record);
}
const browser = record.source === 'webusb';
const source = browser ? 'webusb' : 'feed';
const key = browser
? `webusb:${record.altitudeSessionAt ?? 'unknown'}`
: `feed:${record.feedId ?? 'unknown'}:${record.band ?? '1090'}`;
const state = altitudeSourceState(gate, key, at);
const candidate = {
altitudeFt,
at,
verticalRateFpm: finiteOrNull(record.verticalRateFpm),
observedAt: null,
source,
quality: browser ? 'accepted' : 'feed-estimate',
};
if (browser) judgeBrowserAltitude(record, gate, state, candidate);
else judgeFeedAltitude(record, gate, state, candidate);
return altitudeResult(record, gate.accepted);
}
function normalizePosition(lat, lon) {
const latitude = finiteOrNull(lat);
const longitude = finiteOrNull(lon);
@@ -196,6 +490,9 @@ export function recordFromDecoderTrack(track) {
lat,
lon,
altitudeFt: finiteOrNull(track.altitudeFt),
altitudeObservedAt: finiteOrNull(track.lastAltitudeAt),
altitudeSequence: finiteOrNull(track.altitudeSequence),
altitudeSessionAt: finiteOrNull(track.firstSeenAt),
groundSpeedKt: finiteOrNull(track.speedKt),
trackDeg: normalizeTrack(track.headingDeg),
verticalRateFpm: finiteOrNull(track.verticalRateFpm),
@@ -221,9 +518,14 @@ export function recordFromDecoderTrack(track) {
* @param {number} nowMs Local epoch ms at which the document was received.
* @param {object} [options]
* @param {'1090'|'978'} [options.band='1090'] Band the feed decodes.
* @param {string|null} [options.feedId=null] Opaque configured receiver identity.
* @returns {object[]} Normalized records.
*/
export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
export function normalizeDump1090Aircraft(
json,
nowMs,
{ band = '1090', feedId = null } = {},
) {
const recordBand = band === '978' ? '978' : '1090';
const receivedAt = finiteOrNull(nowMs);
if (receivedAt === null || !Array.isArray(json?.aircraft)) return [];
@@ -245,6 +547,10 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
lat: hasPosition ? position.lat : null,
lon: hasPosition ? position.lon : null,
altitudeFt: altitude,
altitudeObservedAt: null,
feedId: typeof feedId === 'string' ? feedId : null,
feedSnapshotAt:
finiteOrNull(json.now) === null ? null : finiteOrNull(json.now * 1000),
groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track),
verticalRateFpm: finiteOrNull(entry.baro_rate),
+548
View File
@@ -1,11 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
decodeAdsbMessage,
modeSChecksum,
updateAircraftTrack,
} from '../sdr/adsbDecoder.js';
import {
LOCAL_ADSB_MESSAGE_STALE_MS,
LOCAL_ADSB_POSITION_STALE_MS,
LOCAL_ADSB_REFERENCE_MAX_AGE_MS,
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbAltitudeIsPlausible,
localAdsbFixIsPlausible,
localAdsbPositionIsFresh,
localAdsbRecordIsLive,
@@ -40,6 +48,9 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => {
lat: 30.269662,
lon: -97.793262,
altitudeFt: 1600,
altitudeObservedAt: null,
feedId: null,
feedSnapshotAt: dump1090.now * 1000,
groundSpeedKt: 129.9,
trackDeg: 330.5,
verticalRateFpm: 64,
@@ -114,6 +125,9 @@ test('decoder tracks map to the same record shape', () => {
verticalRateFpm: 64,
messages: 48,
lastSeen: 5_000,
firstSeenAt: 1_000,
lastAltitudeAt: 3_000,
altitudeSequence: 2,
lastPositionAt: 4_000,
cprEven: {},
});
@@ -125,6 +139,9 @@ test('decoder tracks map to the same record shape', () => {
lat: 30.27,
lon: -97.79,
altitudeFt: 1600,
altitudeObservedAt: 3_000,
altitudeSequence: 2,
altitudeSessionAt: 1_000,
groundSpeedKt: 129.9,
trackDeg: 330.5,
verticalRateFpm: 64,
@@ -324,3 +341,534 @@ test('the position sanity check follows dump1090: speed × 1.5 + margin over ela
'the fixed 500 m margin absorbs CPR and reception error',
);
});
test('the altitude check allows the vertical rate × 1.5 + margin over elapsed + 1 s', () => {
const from = { altitudeFt: 30_000, at: 0, verticalRateFpm: null };
// 9 s later, so the allowance covers 10 s: a sixth of a minute.
const at = 9_000;
const check = (altitudeFt, rates = {}) =>
localAdsbAltitudeIsPlausible(
{ ...from, verticalRateFpm: rates.previous ?? null },
{ altitudeFt, at, verticalRateFpm: rates.next ?? null },
);
assert.equal(
localAdsbAltitudeIsPlausible(null, { altitudeFt: 108_800, at }),
true,
'no reference',
);
// Unknown rate: 12,500 ft/min → 2,083 ft + 300 ft.
assert.equal(check(32_380), true);
assert.equal(check(32_390), false);
assert.equal(check(27_620), true, 'the limit is symmetric');
assert.equal(check(27_610), false);
// Level flight reported: 2,000 ft/min → 333 ft + 300 ft.
assert.equal(check(30_630, { previous: 0 }), true);
assert.equal(check(30_640, { previous: 0 }), false);
// The larger of the two reported rates counts: 6,000 × 1.5 + 2,000
// = 11,000 ft/min → 1,833 ft + 300 ft.
assert.equal(check(27_870, { previous: 0, next: -6_000 }), true);
assert.equal(check(27_860, { previous: 0, next: -6_000 }), false);
assert.equal(
localAdsbAltitudeIsPlausible(
{ altitudeFt: 30_000, at: 9_000 },
{ altitudeFt: 30_400, at: 0 },
),
true,
'an out-of-order report gets the zero-gap allowance, not a negative one',
);
});
/** Feed actual browser altitude reports, `[seconds, altitudeFt, extra]`. */
function gateReports(reports) {
const gate = createLocalAdsbAltitudeGate();
const altitudes = reports.map(
([seconds, altitudeFt, extra = {}]) =>
gateLocalAdsbAltitude(
{
icao: 'abc123',
lastMessageAt: seconds * 1_000,
source: 'webusb',
altitudeObservedAt: seconds * 1_000,
altitudeSequence: seconds * 1_000 + 1,
altitudeSessionAt: 0,
altitudeFt,
...extra,
},
gate,
).altitudeFt,
);
return { altitudes, rejected: gate.rejected };
}
test('a corrupt altitude is held at the last accepted one and judged once', () => {
// The logged case: a DC-9 cruising at 35,000 ft reported once at 108,800 ft.
// The layer re-reads the same record several times a second; a re-read of
// the refused report is neither let through nor counted again.
assert.deepEqual(
gateReports([
[0, 35_000],
[0.5, 35_000],
[1, 108_800],
[1, 108_800],
[1, 108_800],
[1.5, 35_025],
]),
{
altitudes: [35_000, 35_000, 35_000, 35_000, 35_000, 35_025],
rejected: 1,
},
);
});
test('a bad first altitude clears itself on the third refusal', () => {
assert.deepEqual(
gateReports([
[0, 108_800],
[0.5, 35_000],
[1, 35_000],
[1.5, 35_000],
[2, 35_000],
]),
{ altitudes: [108_800, 108_800, 108_800, 35_000, 35_000], rejected: 3 },
);
});
test('a surface report is not compared with the last airborne altitude', () => {
// Denver: the last airborne report is 5,600 ft barometric, and a feed
// reports `alt_baro: "ground"` as 0. Neither the landing nor the next
// takeoff is a jump.
assert.deepEqual(
gateReports([
[0, 5_600],
[20, 0, { onGround: true }],
[300, 5_700],
[301, 5_720],
]),
{ altitudes: [5_600, 0, 5_700, 5_720], rejected: 0 },
);
});
function browserAltitude(altitudeFt, at, sequence, extra = {}) {
return {
icao: 'abc123',
source: 'webusb',
altitudeFt,
altitudeObservedAt: at,
altitudeSequence: sequence,
altitudeSessionAt: 0,
lastMessageAt: at,
...extra,
};
}
function feedAltitude(altitudeFt, at, messages, extra = {}) {
return {
icao: 'abc123',
source: 'feed',
band: '1090',
feedId: 'feed-1',
feedSnapshotAt: at,
messageCount: messages,
altitudeFt,
altitudeObservedAt: null,
lastMessageAt: at,
...extra,
};
}
test('non-altitude messages never recount a refusal or refresh its reference', () => {
const gate = createLocalAdsbAltitudeGate();
const first = browserAltitude(35_000, 0, 1);
const bad = browserAltitude(108_800, 1_000, 2);
gateLocalAdsbAltitude(first, gate, 0);
let result = gateLocalAdsbAltitude(bad, gate, 1_000);
for (const at of [2_000, 3_000, 9_000]) {
result = gateLocalAdsbAltitude({ ...bad, lastMessageAt: at }, gate, at);
assert.equal(result.altitudeFt, 35_000);
assert.equal(result.altitudeObservedAt, 0);
assert.equal(result.altitudeQuality, 'held-after-rejection');
}
assert.equal(gate.rejected, 1);
// The true report is compared with the real 0 ms observation, not the
// most recent identification/velocity message at 9 seconds.
result = gateLocalAdsbAltitude(
browserAltitude(37_000, 10_000, 3),
gate,
10_000,
);
assert.equal(result.altitudeFt, 37_000);
assert.equal(result.altitudeObservedAt, 10_000);
assert.equal(result.altitudeQuality, 'accepted');
assert.equal(gate.rejected, 1);
assert.equal(bad.altitudeFt, 108_800, 'input records are not rewritten');
});
test('same-valued real frames refresh time; same-ms frames each get one vote', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0);
let result = gateLocalAdsbAltitude(
browserAltitude(35_000, 10_000, 2),
gate,
10_000,
);
assert.equal(result.altitudeObservedAt, 10_000);
result = gateLocalAdsbAltitude(
browserAltitude(37_000, 11_000, 3),
gate,
11_000,
);
assert.equal(result.altitudeFt, 35_000, 'recent level report limits the gap');
for (const sequence of [4, 5])
result = gateLocalAdsbAltitude(
browserAltitude(37_000, 11_000, sequence),
gate,
11_000,
);
assert.equal(result.altitudeFt, 37_000);
assert.equal(
gate.rejected,
3,
'different frames in one USB block count individually',
);
});
test('a browser sequence gap is one observation, and old/repeated tokens do not vote', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0);
const bad = browserAltitude(108_800, 1_000, 15);
for (let n = 0; n < 4; n++) gateLocalAdsbAltitude(bad, gate, 1_000 + n);
gateLocalAdsbAltitude(browserAltitude(108_800, 500, 3), gate, 2_000);
assert.equal(gate.rejected, 1);
assert.equal(gate.accepted.altitudeFt, 35_000);
});
test('sources and a new browser track cannot combine recovery streaks', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(browserAltitude(35_000, 0, 1), gate, 0);
gateLocalAdsbAltitude(browserAltitude(108_800, 1_000, 2), gate, 1_000);
gateLocalAdsbAltitude(feedAltitude(108_800, 2_000, 2), gate, 2_000);
let result = gateLocalAdsbAltitude(
browserAltitude(108_800, 3_000, 3),
gate,
3_000,
);
assert.equal(
result.altitudeFt,
35_000,
'two browser votes plus a feed refusal are not three browser votes',
);
result = gateLocalAdsbAltitude(
browserAltitude(108_800, 4_000, 1, { altitudeSessionAt: 4_000 }),
gate,
4_000,
);
assert.equal(result.altitudeFt, 35_000, 'new track starts its own streak');
});
test("alternating receivers recover using each receiver's own observations", () => {
for (const secondSource of ['browser', 'feed']) {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0);
let result;
for (let at = 1_000; at <= 7_000; at += 1_000) {
const alternate = at % 2_000 === 0;
const record =
alternate && secondSource === 'browser'
? browserAltitude(35_000, at, at / 1_000)
: feedAltitude(
35_000,
at,
at / 1_000 + 1,
alternate ? { feedId: 'feed-2' } : {},
);
result = gateLocalAdsbAltitude(record, gate, at);
}
assert.equal(result.altitudeFt, 35_000, secondSource);
}
});
test("a newly accepted reference clears every receiver's pending recovery", () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0);
gateLocalAdsbAltitude(feedAltitude(35_000, 1_000, 2), gate, 1_000);
gateLocalAdsbAltitude(feedAltitude(35_000, 3_000, 3), gate, 3_000);
for (const at of [4_000, 4_500, 5_000])
gateLocalAdsbAltitude(browserAltitude(80_000, at, at), gate, at);
const result = gateLocalAdsbAltitude(
feedAltitude(35_000, 6_000, 4),
gate,
6_000,
);
assert.equal(
result.altitudeFt,
80_000,
'old votes cannot re-anchor against the new reference',
);
});
test('a feed wall-clock rollback judges values and starts a fresh recovery window', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(35_000, 60_000, 100), gate, 60_000);
const descent = gateLocalAdsbAltitude(
feedAltitude(33_750, 40_000, 101),
gate,
70_000,
);
assert.equal(
descent.altitudeFt,
33_750,
'a clock step cannot freeze a plausible descent',
);
assert.equal(descent.altitudeQuality, 'feed-estimate');
for (const [raw, at, count] of [
[41_000, 71_000, 102],
[43_000, 73_000, 103],
])
gateLocalAdsbAltitude(feedAltitude(108_800, raw, count), gate, at);
let result = gateLocalAdsbAltitude(
feedAltitude(108_800, 20_000, 104),
gate,
76_000,
);
assert.equal(
result.altitudeFt,
33_750,
'rollback does not reuse the old recovery window',
);
result = gateLocalAdsbAltitude(
feedAltitude(108_800, 22_000, 105),
gate,
78_000,
);
assert.equal(result.altitudeFt, 33_750);
result = gateLocalAdsbAltitude(
feedAltitude(108_800, 25_000, 106),
gate,
81_000,
);
assert.equal(result.altitudeQuality, 'inferred-reanchor');
});
test('held values keep the accepted measurement time and source; ground/missing clears claims', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(browserAltitude(35_000, 100, 1), gate, 100);
const held = gateLocalAdsbAltitude(
feedAltitude(108_800, 1_000, 1),
gate,
1_000,
);
assert.equal(held.source, 'feed', 'position source remains the feed');
assert.equal(held.altitudeSource, 'webusb');
assert.equal(held.altitudeObservedAt, 100);
assert.equal(held.altitudeQuality, 'held-after-rejection');
for (const extra of [
{ altitudeFt: null },
{ altitudeFt: 0, onGround: true },
]) {
const result = gateLocalAdsbAltitude(
feedAltitude(108_800, 2_000, 2, extra),
gate,
2_000,
);
assert.equal(result.altitudeQuality, null);
assert.equal(result.altitudeSource, null);
assert.equal(result.altitudeObservedAt, null);
}
assert.equal(gate.accepted, null, 'ground resets the airborne reference');
const takeoff = gateLocalAdsbAltitude(
browserAltitude(5_700, 3_000, 2),
gate,
3_000,
);
assert.equal(takeoff.altitudeFt, 5_700);
});
test('feed recovery needs five seconds, three advancing snapshots and aircraft activity', () => {
const gate = createLocalAdsbAltitudeGate();
let result = gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), gate, 0);
assert.equal(result.altitudeQuality, 'feed-estimate');
for (const [at, count] of [
[1_000, 2],
[2_000, 3],
[3_000, 4],
[5_999, 5],
]) {
result = gateLocalAdsbAltitude(feedAltitude(35_000, at, count), gate, at);
assert.equal(
result.altitudeFt,
108_800,
'minimum recovery duration has not elapsed',
);
}
result = gateLocalAdsbAltitude(feedAltitude(35_000, 6_000, 6), gate, 6_000);
assert.equal(result.altitudeFt, 35_000);
assert.equal(result.altitudeQuality, 'inferred-reanchor');
assert.equal(
result.altitudeObservedAt,
null,
'feed has no measurement timestamp',
);
assert.equal(result.altitudeSource, 'feed');
assert.equal(gate.rejected, 1, 'retained altitude was refused once');
result = gateLocalAdsbAltitude(feedAltitude(35_025, 7_000, 7), gate, 7_000);
assert.equal(
result.altitudeQuality,
'feed-estimate',
'a changed plausible value is an estimate',
);
const sparse = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(108_800, 0, 1), sparse, 0);
gateLocalAdsbAltitude(feedAltitude(35_000, 1_000, 2), sparse, 1_000);
result = gateLocalAdsbAltitude(feedAltitude(35_000, 7_000, 3), sparse, 7_000);
assert.equal(
result.altitudeFt,
108_800,
'two snapshots do not suffice even after five seconds',
);
result = gateLocalAdsbAltitude(feedAltitude(35_000, 8_000, 4), sparse, 8_000);
assert.equal(result.altitudeFt, 35_000);
});
test('cached feed snapshots, message-free snapshots and missing metadata cannot infer recovery', () => {
for (const mode of ['cached', 'no-messages', 'no-snapshot', 'no-id']) {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(35_000, 0, 1), gate, 0);
for (const at of [1_000, 2_000, 4_000, 7_000, 20_000]) {
const extra =
mode === 'no-snapshot'
? { feedSnapshotAt: null }
: mode === 'no-id'
? { feedId: null }
: {};
const record = feedAltitude(
108_800,
mode === 'cached' ? 1_000 : at,
mode === 'cached' || mode === 'no-messages' ? 2 : at,
extra,
);
const result = gateLocalAdsbAltitude(
{ ...record, lastMessageAt: at },
gate,
at,
);
assert.equal(result.altitudeFt, 35_000, mode);
}
assert.equal(gate.rejected, 1, mode);
}
});
test('incoherent feed candidates, counter reset and clock regression restart recovery', () => {
for (const mode of ['incoherent', 'counter-reset', 'clock-regression']) {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(35_000, 0, 1), gate, 0);
gateLocalAdsbAltitude(feedAltitude(108_800, 1_000, 2), gate, 1_000);
gateLocalAdsbAltitude(feedAltitude(108_800, 3_000, 3), gate, 3_000);
const broken =
mode === 'incoherent'
? feedAltitude(80_000, 6_000, 4)
: mode === 'counter-reset'
? feedAltitude(108_800, 6_000, 1)
: feedAltitude(108_800, 2_000, 4);
let result = gateLocalAdsbAltitude(broken, gate, 6_000);
assert.equal(result.altitudeFt, 35_000, mode);
result = gateLocalAdsbAltitude(
feedAltitude(broken.altitudeFt, 7_000, 5),
gate,
7_000,
);
assert.equal(
result.altitudeFt,
35_000,
`${mode}: no old recovery duration/votes`,
);
}
});
test('feed altitude changes keep a real gap allowance but retained values cannot age it without bound', () => {
const gate = createLocalAdsbAltitudeGate();
gateLocalAdsbAltitude(feedAltitude(30_000, 0, 1), gate, 0);
for (const at of [1_000, 5_000, 9_000])
gateLocalAdsbAltitude(feedAltitude(30_000, at, at), gate, at);
const climb = gateLocalAdsbAltitude(
feedAltitude(32_000, 10_000, 20),
gate,
10_000,
);
assert.equal(
climb.altitudeFt,
32_000,
'non-altitude activity did not shrink the altitude gap',
);
for (let at = 20_000; at <= 1_800_000; at += 20_000)
gateLocalAdsbAltitude(feedAltitude(32_000, at, at), gate, at);
const spike = gateLocalAdsbAltitude(
feedAltitude(108_800, 1_801_000, 1_801_000),
gate,
1_801_000,
);
assert.equal(
spike.altitudeFt,
32_000,
'fresh positions cannot grow an unlimited altitude allowance',
);
assert.equal(spike.altitudeQuality, 'held-after-rejection');
const balloon = gateLocalAdsbAltitude(
feedAltitude(120_000, 0, 1),
createLocalAdsbAltitudeGate(),
0,
);
assert.equal(
balloon.altitudeFt,
120_000,
'this is a relative filter with no absolute ceiling',
);
});
test('CRC-valid decoder frames retain one refused altitude through interleaved message types', () => {
const tracks = new Map();
const gate = createLocalAdsbAltitudeGate();
const hex = (s) => Uint8Array.from(s.match(/../g), (p) => parseInt(p, 16));
const parity = (bytes) => {
bytes.fill(0, 11);
const crc = modeSChecksum(bytes.slice(0, 11));
bytes[11] = crc >>> 16;
bytes[12] = (crc >>> 8) & 0xff;
bytes[13] = crc & 0xff;
assert.equal(modeSChecksum(bytes), 0);
return bytes;
};
const position = hex('8D40621D58C382D690C8AC2863A7');
const low = position.slice();
const n = (2_000 + 1_000) / 25;
const field = ((n >> 4) << 5) | 0x10 | (n & 0xf);
low[5] = field >> 4;
low[6] = ((field & 0xf) << 4) | (low[6] & 0xf);
const id = parity(hex('8D40621D202CC371C32CE0000000'));
const velocity = parity(hex('8D40621D99440994083817000000'));
const apply = (bytes, at) =>
gateLocalAdsbAltitude(
recordFromDecoderTrack(
updateAircraftTrack(
tracks,
decodeAdsbMessage(bytes, { receivedAt: at }),
),
),
gate,
at,
);
apply(position, 0);
let result = apply(parity(low), 1_000);
for (const [bytes, at] of [
[id, 2_000],
[velocity, 3_000],
[id, 4_000],
]) {
result = apply(bytes, at);
assert.equal(result.altitudeFt, 38_000);
assert.equal(result.altitudeObservedAt, 0);
}
assert.equal(gate.rejected, 1);
assert.equal(result.altitudeQuality, 'held-after-rejection');
assert.equal(apply(position, 5_000).altitudeQuality, 'accepted');
});
+12 -8
View File
@@ -1,5 +1,3 @@
import { syncSegmentedControl } from './segmentedControl.js';
/** Own the shared aircraft display preference and its controls. */
export class AircraftDisplay {
constructor({ elements, readDataManager, layout }) {
@@ -30,9 +28,12 @@ export class AircraftDisplay {
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState();
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
valueOf: (button) => button.dataset.mode,
});
for (const button of this._models3dModeBtns || []) {
if (!button) continue;
const active = button.dataset.mode === this._models3dMode;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
this._layoutRightPanels();
}
@@ -58,9 +59,12 @@ export class AircraftDisplay {
const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized });
syncSegmentedControl(this._models3dModeBtns, normalized, {
valueOf: (button) => button.dataset.mode,
});
for (const button of this._models3dModeBtns) {
if (!button) continue;
const active = button.dataset.mode === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
this._syncModels3dButtonState();
}
+6 -21
View File
@@ -1,5 +1,3 @@
import { createSegmentedControl } from './segmentedControl.js';
/**
* Bind Display controls to application actions. Inputs remain native DOM controls;
* the caller owns settings and effect state. Destroy before replacing the view.
@@ -46,25 +44,12 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const [buttons, valueOf, action] of [
[
elements.allocationButtons || [],
(el) => el.dataset.allocation,
'setAllocation',
],
[
elements.modelModeButtons || [],
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
'setModelsMode',
],
]) {
const control = createSegmentedControl({
buttons,
valueOf,
onChange: (value) => actions[action](value),
});
removers.push(() => control.destroy());
}
for (const el of elements.allocationButtons || [])
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
for (const el of elements.modelModeButtons || [])
listen(el, 'click', 'setModelsMode', (el) =>
el.dataset.mode === 'all' ? 'all' : 'proximity',
);
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
return {
destroy() {
+6 -6
View File
@@ -2,7 +2,6 @@ import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { formatScaleLabel, unitLabel } from './unitLabels.js';
import { createSlider } from './slider.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -91,11 +90,12 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = createSlider({
documentRef: document,
className: 'rail-card-setting-range',
});
block.rangeRow.appendChild(block.rangeInput);
block.rangeInput = make(
'input',
'rail-card-setting-range',
block.rangeRow,
);
block.rangeInput.type = 'range';
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
+8 -10
View File
@@ -1,5 +1,4 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createSlider } from './slider.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
@@ -44,15 +43,14 @@ export function createRailTimeline({
return node;
};
const previous = makeButton('‹', t('Earlier observation'), row);
const slider = createSlider({
documentRef: document,
className: sliderClassName,
min: '0',
max: '0',
step: '1',
value: '0',
ariaLabel: t('Observed history'),
});
const slider = document.createElement('input');
slider.type = 'range';
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
slider.min = '0';
slider.max = '0';
slider.step = '1';
slider.value = '0';
slider.setAttribute('aria-label', t('Observed history'));
row.appendChild(slider);
const next = makeButton('›', t('Later observation'), row);
const controls = document.createElement('div');
+33 -20
View File
@@ -12,8 +12,6 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { createRailCardBlocks } from './railCardBlocks.js';
import { createRailCards } from './railCards.js';
import { createImagerySplit } from './imagerySplit.js';
import { createSlider } from './slider.js';
import { createSegmentedControl } from './segmentedControl.js';
import {
PRODUCTS,
hhmm,
@@ -288,13 +286,13 @@ export function createRecentImageryPanel({
const selection = el('section', 'ri-block ri-selection', root);
selection.id = 'ri-selection';
selection.setAttribute('aria-label', t('On the map'));
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
const modeGroup = el('div', 'ri-mode', selection);
modeGroup.id = 'ri-mode';
modeGroup.setAttribute('role', 'radiogroup');
modeGroup.setAttribute('aria-label', t('Mode'));
const modeButtons = new Map(
MODE_LABELS.map(([mode, label]) => {
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
node.type = 'button';
node.textContent = t(label);
node.dataset.mode = mode;
@@ -302,15 +300,6 @@ export function createRecentImageryPanel({
return [mode, node];
}),
);
const modeControl = createSegmentedControl({
buttons: modeButtons.values(),
valueOf: (button) => button.dataset.mode,
onChange: (mode) => layer.setMode(mode),
keyboard: 'arrows',
keyTarget: modeGroup,
readValue: () => snapshot.mode,
listen,
});
const slotRows = {};
for (const slotId of ['a', 'b']) {
const row = el('div', 'ri-slot', selection);
@@ -329,17 +318,16 @@ export function createRecentImageryPanel({
const opacityLabel = el('label', 'ri-opacity-label', controls);
el('span', '', opacityLabel, t('OPACITY'));
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
const opacity = createSlider({
documentRef: document,
className: 'ri-opacity',
const opacity = el('input', 'ri-opacity', controls);
opacity.id = 'ri-opacity';
Object.assign(opacity, {
type: 'range',
min: '0',
max: '100',
step: '1',
value: '100',
ariaLabel: t('Imagery opacity'),
});
opacity.id = 'ri-opacity';
controls.appendChild(opacity);
opacity.setAttribute('aria-label', t('Imagery opacity'));
opacityLabel.htmlFor = 'ri-opacity';
// Its slot is always there; it is only usable while a swipe is live.
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
@@ -680,7 +668,12 @@ export function createRecentImageryPanel({
function renderSelection() {
const { mode } = snapshot;
set(selection.dataset, 'mode', mode);
modeControl.update(mode, { rovingFocus: true });
for (const [id, node] of modeButtons) {
const on = id === mode;
attribute(node, 'aria-checked', String(on));
toggle(node, 'active', on);
set(node, 'tabIndex', on ? 0 : -1);
}
const ab = mode === 'ab';
for (const slotId of ['a', 'b']) {
const { row, tag, value, unpin } = slotRows[slotId];
@@ -1072,6 +1065,11 @@ export function createRecentImageryPanel({
strip.focus({ preventScroll: true });
return;
}
const mode = target?.closest?.('.ri-mode-btn');
if (mode) {
layer.setMode(mode.dataset.mode);
return;
}
if (target?.closest?.('.ri-swap') && !swap.disabled) {
layer.swapSides?.();
return;
@@ -1088,6 +1086,21 @@ export function createRecentImageryPanel({
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
});
listen(modeGroup, 'keydown', (event) => {
const order = MODE_LABELS.map(([mode]) => mode);
const step =
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
0;
if (!step) return;
event.preventDefault();
const next =
order[
(order.indexOf(snapshot.mode) + step + order.length) % order.length
];
layer.setMode(next);
modeButtons.get(next)?.focus?.();
});
listen(strip, 'keydown', (event) => {
const total = snapshot.candidates.length;
const index = snapshot.focusIndex || 0;
+3 -3
View File
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
assert.match(
rule('.recent-imagery-readout .ri-selection'),
/grid-template-rows: 26px 20px 20px/,
/grid-template-rows: 20px 20px 20px/,
);
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
/**
* Every block that must keep its height: the node carrying the class, that
* class, and the rule it gets its height from. The mode row is the first
* 26 px track of the selection grid. The details header has no height of
* 20 px track of the selection grid. The details header has no height of
* its own: it is the last block, so it moves nothing below it, and its
* title is held to one line.
*/
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
mode.parentNode.children[0] === mode ? mode.parentNode : null,
'ri-selection',
readout('.ri-selection'),
/grid-template-rows: 26px 20px 20px/,
/grid-template-rows: 20px 20px 20px/,
],
'ri-slot-a': [
f.byId('ri-slot-a'),
-109
View File
@@ -1,109 +0,0 @@
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
/** Pure value navigation, also usable by a future template-based view. */
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
if (!values.length) return undefined;
if (homeEnd && key === 'Home') return values[0];
if (homeEnd && key === 'End') return values.at(-1);
const step = STEPS[key];
if (!step) return undefined;
return values[(values.indexOf(value) + step + values.length) % values.length];
}
/** Paint controlled selection without emitting changes or moving focus. */
export function syncSegmentedControl(
buttons,
value,
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
) {
for (const button of buttons || []) {
if (!button) continue;
const selected = valueOf(button) === value;
if (button.classList.contains(activeClass) !== selected)
button.classList.toggle(activeClass, selected);
const set = (name, content) => {
if (button.getAttribute?.(name) !== content)
button.setAttribute(name, content);
};
set('aria-checked', String(selected));
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
}
}
/**
* Controlled segmented-selector DOM adapter. Feature owners supply values and
* receive changes; rendering a restored value never triggers the callback.
* `keyboard` retains each existing group's policy: none, arrows, or radio
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
* An injected listener owner keeps the feature's existing teardown boundary.
* @param {object} options
* @param {Iterable<HTMLButtonElement>} options.buttons
* @param {Function} options.valueOf Maps a button to the feature's value.
* @param {Function} options.onChange Receives value and originating button.
* @param {'none'|'arrows'|'radio'} [options.keyboard]
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
* @param {Function} [options.readValue] Current controlled value for delegation.
* @param {Function} [options.listen] Feature-owned event registration.
* @returns {{ update: Function, destroy: Function }}
*/
export function createSegmentedControl({
buttons,
valueOf,
onChange,
keyboard = 'none',
keyTarget,
readValue,
listen,
}) {
const options = [...buttons].filter(Boolean);
const removers = [];
let destroyed = false;
const bind = (target, type, handler) => {
const owned = (event) => {
if (!destroyed) handler(event);
};
if (listen) listen(target, type, owned);
else {
target.addEventListener(type, owned);
removers.push(() => target.removeEventListener(type, owned));
}
};
for (const button of options)
bind(button, 'click', () => {
if (!button.disabled) onChange(valueOf(button), button);
});
const navigate = (event, from) => {
const enabled = options.filter((button) => !button.disabled);
const values = enabled.map(valueOf);
const next = nextSegmentValue(values, from, event.key, {
homeEnd: keyboard === 'radio',
});
if (next === undefined) return;
const button = enabled.find((option) => valueOf(option) === next);
event.preventDefault();
if (keyTarget) {
button.click();
button.focus();
} else {
button.focus();
if (next !== from) button.click();
}
};
if (keyboard !== 'none') {
if (keyTarget)
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
else
for (const button of options)
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
}
return {
update(value, presentation) {
if (!destroyed)
syncSegmentedControl(options, value, { valueOf, ...presentation });
},
destroy() {
destroyed = true;
for (const remove of removers.splice(0)) remove();
},
};
}
-174
View File
@@ -1,174 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { railFixture } from './railTestFixture.mjs';
import {
createSegmentedControl,
nextSegmentValue,
} from './segmentedControl.js';
function fixture(options = {}) {
let writes = 0;
const f = railFixture(() => writes++);
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (button) => button.dataset.value,
onChange: (value) => calls.push(value),
...options,
});
const key = (target, value) => {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value });
target.dispatchEvent(event);
return event.defaultPrevented;
};
return { ...f, buttons, calls, control, key, writes: () => writes };
}
test('controlled restoration updates selection without emitting or stealing focus', () => {
const f = fixture();
f.buttons[0].focus();
f.control.update('b', { rovingFocus: true });
assert.deepEqual(f.calls, []);
assert.equal(f.document.activeElement, f.buttons[0]);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('aria-checked')),
['false', 'true', 'false'],
);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('tabindex')),
['-1', '0', '-1'],
);
const before = f.writes();
f.control.update('b', { rovingFocus: true });
assert.equal(
f.writes(),
before,
'unchanged renders must not retrigger rail observers',
);
f.control.destroy();
});
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
const f = fixture();
f.control.update('a');
f.buttons[0].click();
f.buttons[0].click();
f.buttons[1].dataset.value = 'renamed';
f.buttons[1].click();
f.buttons[2].disabled = true;
f.buttons[2].dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
assert.equal(
f.buttons[0].getAttribute('aria-checked'),
'true',
'the owner controls selection',
);
f.control.destroy();
});
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
const f = fixture({ keyboard: 'radio' });
f.buttons[1].disabled = true;
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c']);
f.key(f.buttons[2], 'ArrowDown');
assert.equal(f.document.activeElement, f.buttons[0]);
f.key(f.buttons[0], 'Home');
assert.deepEqual(
f.calls,
['c', 'a'],
'Home on the first button does not reselect it',
);
f.key(f.buttons[0], 'End');
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c', 'a', 'c']);
f.control.destroy();
});
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
const f = fixture();
f.control.update('b');
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
assert.equal(f.key(f.buttons[0], 'Home'), false);
assert.deepEqual(f.calls, []);
assert.ok(
f.buttons.every((button) => button.getAttribute('tabindex') === null),
);
f.control.destroy();
});
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
const f = railFixture();
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
let selected = 'c';
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => {
calls.push(value);
selected = value;
},
keyboard: 'arrows',
keyTarget: f.container,
readValue: () => selected,
});
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
f.container.dispatchEvent(event);
assert.deepEqual(calls, ['a']);
assert.equal(f.document.activeElement, buttons[0]);
const home = new Event('keydown', { cancelable: true });
Object.defineProperty(home, 'key', { value: 'Home' });
f.container.dispatchEvent(home);
assert.equal(home.defaultPrevented, false);
assert.deepEqual(calls, ['a']);
control.destroy();
});
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
const f = fixture({ keyboard: 'radio' });
f.control.destroy();
const next = [];
const replacement = createSegmentedControl({
buttons: f.buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => next.push(value),
keyboard: 'radio',
});
f.buttons[0].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(f.calls, []);
assert.deepEqual(next, ['a', 'c']);
replacement.destroy();
replacement.destroy();
f.buttons[1].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(next, ['a', 'c']);
});
test('pure value navigation can be reused without a DOM', () => {
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
assert.equal(
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
undefined,
);
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
});
-32
View File
@@ -1,32 +0,0 @@
/**
* Build the shared native slider. Feature owners retain their input/change
* listeners, formatting and teardown; this component installs no handlers.
* Static sliders use the same gev-quantitative-slider class in their template.
* @param {object} options
* @param {Document} [options.documentRef] Document that owns the input.
* @param {string} [options.className] Additional feature/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createSlider({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'range';
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+53 -29
View File
@@ -1,9 +1,5 @@
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import { syncChipGroup } from './chipGroup.js';
import {
createSegmentedControl,
syncSegmentedControl,
} from './segmentedControl.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
@@ -13,6 +9,12 @@ import { t, translateKnown } from '../i18n/index.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
// Renders run on every layer notification, and the rail's MutationObserver
// relays out on each attribute write: write only what changed.
@@ -104,13 +106,13 @@ export class StreetLevelControls {
}
this._toggleProvider(button.dataset.chipId);
});
createSegmentedControl({
buttons: el.panoButtons,
valueOf: (button) => button.dataset.slPano,
onChange: (pano) => this._setParams({ pano }),
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
});
for (const button of el.panoButtons) {
this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
@@ -158,20 +160,17 @@ export class StreetLevelControls {
this._exitExpanded();
});
} else setProp(el.viewerExpand, 'hidden', true);
createSegmentedControl({
buttons: el.renderButtons,
valueOf: (button) => button.dataset.slRender,
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
onChange: (mode) => {
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
this.layer.setViewerRenderMode?.(mode);
try {
localStorage.setItem(RENDER_MODE_KEY, mode);
} catch {
/* storage unavailable */
}
},
});
});
}
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
@@ -186,6 +185,24 @@ export class StreetLevelControls {
}
}
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
@@ -399,6 +416,15 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
}
}
_renderFilters(view) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
@@ -408,11 +434,10 @@ export class StreetLevelControls {
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
syncSegmentedControl(el.panoButtons, view.filter.pano, {
valueOf: (button) => button.dataset.slPano,
activeClass: 'is-active',
rovingFocus: true,
});
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
if (el.sinceRange) {
// Never move the thumb under the user's hand; once the committed value
// matches the thumb, show the full readout with its cut-off date.
@@ -442,11 +467,10 @@ export class StreetLevelControls {
setProp(wrap, 'hidden', !viewer.open);
}
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
valueOf: (button) => button.dataset.slRender,
activeClass: 'is-active',
rovingFocus: true,
});
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
// Google's viewer always fills its frame: no FIT/FILL to offer.
const renderGroup = el.renderButtons[0]?.parentElement;
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
+2 -2
View File
@@ -981,9 +981,9 @@ test('a note under SINCE says what date it compares, only while one is given', (
const { layer } = stubPanel(dom, state);
const note = () => dom.root.querySelector('#sl-since-note');
assert.equal(note().hidden, true, 'none for Mapillary');
layer.publish({ ...state, sinceNote: '(by upload date)' });
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
assert.equal(note().hidden, false);
assert.equal(note().textContent, '(by upload date)');
assert.equal(note().textContent, 'KartaView: by upload date');
layer.publish(state);
assert.equal(note().hidden, true);
// The panel's own markup has it, read out with the slider.
+8 -10
View File
@@ -1,5 +1,4 @@
import { formatNumber, t } from '../i18n/index.js';
import { createSlider } from './slider.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
@@ -42,15 +41,14 @@ export function createStyleParameters({
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = t(metadata.label);
const slider = createSlider({
documentRef,
className: 'param-slider',
ariaLabel: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
value: readValue(name),
});
const slider = documentRef.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', t(metadata.label));
slider.min = metadata.min;
slider.max = metadata.max;
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
slider.value = readValue(name);
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
+172 -4
View File
@@ -532,7 +532,110 @@ html:not([lang='en']) .panel-collapse-btn {
.pp-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 60px;
}
.pp-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.pp-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.pp-slider-value {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
color: var(--accent);
min-width: 28px;
text-align: right;
flex-shrink: 0;
}
/* Quantitative companion sliders share Display / Sharpen's visible rail,
* circular thumb, glow, and value treatment while retaining a taller hit area. */
.gev-quantitative-slider {
width: 100%;
min-width: 0;
height: 18px;
margin: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gev-quantitative-slider::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-webkit-slider-thumb {
width: 10px;
height: 10px;
margin-top: -3.5px;
border: 0;
border-radius: 50%;
-webkit-appearance: none;
appearance: none;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider::-moz-range-track,
.gev-quantitative-slider::-moz-range-progress {
height: 3px;
border: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider:focus-visible {
border-radius: 4px;
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
outline-offset: 2px;
}
.gev-quantitative-slider:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.gev-slider-value {
min-width: 28px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
text-align: right;
flex-shrink: 0;
}
.pp-slider-mini-label {
@@ -574,6 +677,11 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
padding: 5px 8px 6px;
}
.detection-allocation-seg .pp-mode-btn {
font-size: 8px;
letter-spacing: 0.35px;
}
#hud-layout-row {
min-width: 200px;
}
@@ -591,7 +699,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
.cyber-sonar-toggle-row .pp-mode-btn {
margin-left: auto;
min-width: 48px;
}
@@ -743,6 +851,11 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -871,8 +984,11 @@ body.gev-geo-import-drag #cesiumContainer {
.pp-mode-seg {
display: flex;
flex: 1;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
overflow: hidden;
}
.pp-mode-btn:not(.gev-segment) {
.pp-mode-btn {
flex: 1;
height: 22px;
background: rgba(255, 255, 255, 0.04);
@@ -887,11 +1003,14 @@ body.gev-geo-import-drag #cesiumContainer {
background-color var(--transition-fast),
color var(--transition-fast);
}
.pp-mode-btn:not(.gev-segment):hover {
.pp-mode-btn + .pp-mode-btn {
border-left: 1px solid rgba(255, 255, 255, 0.14);
}
.pp-mode-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.pp-mode-btn:not(.gev-segment).active {
.pp-mode-btn.active {
background: var(--accent);
color: #000;
}
@@ -909,6 +1028,12 @@ body.gev-geo-import-drag #cesiumContainer {
box-shadow: inset 0 0 0 4px #061014;
}
.pp-slider:focus-visible,
.param-slider:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 4px;
}
#clean-view-toggle {
width: var(--pp-expanded-width);
}
@@ -1115,4 +1240,47 @@ html:not([lang='en']) .param-slider-row {
.param-slider {
flex: 1;
min-width: 0;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
transition: box-shadow var(--transition-fast);
}
.param-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 12px var(--accent-glow);
}
.param-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.param-value {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.5px;
color: var(--accent);
min-width: 32px;
text-align: right;
flex-shrink: 0;
}
-2
View File
@@ -506,13 +506,11 @@
0 0 12px rgba(196, 84, 84, 0.14);
}
:root[data-ui-theme='cyber'] .gev-segmented,
:root[data-ui-theme='cyber'] .pp-mode-seg {
border-color: rgba(206, 222, 226, 0.24);
border-radius: 1px;
}
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
background: var(--cyber-red);
color: #f7f8f7;
+2 -2
View File
@@ -184,8 +184,8 @@
max-height: 100%;
pointer-events: auto;
}
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
#right-context-rail > #weather-panel:not(.collapsed),
#right-context-rail > #weather-panel,
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
display: flex;
flex-direction: column;
+13 -2
View File
@@ -309,10 +309,10 @@ html:not([lang='en'])
display: none;
}
/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-rows: 26px 20px 20px;
grid-template-rows: 20px 20px 20px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
@@ -321,6 +321,7 @@ html:not([lang='en'])
.recent-imagery-readout .ri-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-selection .data-toggle-chip {
height: 20px;
@@ -422,6 +423,16 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.recent-imagery-readout .ri-opacity {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--accent);
}
.recent-imagery-readout .ri-opacity:disabled {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
width: 40px;
-51
View File
@@ -1,51 +0,0 @@
/* Shared one-of-many control. Feature classes retain surrounding placement. */
.gev-segmented {
display: inline-flex;
min-width: 0;
border: 1px solid var(--glass-border-hover);
border-radius: 6px;
overflow: hidden;
}
.gev-segmented[hidden] {
display: none;
}
.gev-segment {
flex: 1;
min-width: min-content;
min-height: 24px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 0;
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font: 9px/1.25 var(--font-mono);
letter-spacing: 0.6px;
text-transform: uppercase;
overflow-wrap: normal;
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}
.gev-segment + .gev-segment {
border-left: 1px solid var(--glass-border);
}
.gev-segment:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.gev-segment[aria-checked='true'] {
background: var(--accent);
color: #000;
}
.gev-segment:focus-visible {
position: relative;
outline: 2px solid var(--text-primary);
outline-offset: -3px;
box-shadow: inset 0 0 0 4px #061014;
}
.gev-segment:disabled {
opacity: 0.42;
cursor: not-allowed;
}
-70
View File
@@ -1,70 +0,0 @@
/* Shared ordinary slider surface; feature classes only own surrounding layout.
* Native range inputs retain keyboard, pointer, bounds and step behavior. */
.gev-quantitative-slider {
width: 100%;
min-width: 0;
height: 18px;
margin: 0;
padding: 0;
border: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gev-quantitative-slider::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-webkit-slider-thumb {
width: 10px;
height: 10px;
margin-top: -3.5px;
border: 0;
border-radius: 50%;
-webkit-appearance: none;
appearance: none;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider::-moz-range-track,
.gev-quantitative-slider::-moz-range-progress {
height: 3px;
border: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider:focus-visible {
border-radius: 4px;
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
outline-offset: 2px;
}
.gev-quantitative-slider:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.pp-slider-value,
.param-value,
.ri-opacity-value,
.rail-card-setting-range-value,
.gev-slider-value {
min-width: 28px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
text-align: right;
flex-shrink: 0;
}
+45 -12
View File
@@ -222,6 +222,14 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex-wrap: wrap;
}
/* No overflow clipping: it would cut off the buttons' focus ring. The end
buttons round themselves instead. */
.sl-seg {
display: inline-flex;
border: 1px solid var(--glass-border-hover);
border-radius: 999px;
}
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
@@ -229,6 +237,40 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
padding: 4px 9px;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sl-seg-btn:first-child {
border-radius: 999px 0 0 999px;
}
.sl-seg-btn:last-child {
border-radius: 0 999px 999px 0;
}
.sl-seg-btn + .sl-seg-btn {
border-left: 1px solid var(--glass-border);
}
.sl-seg-btn:focus-visible {
position: relative;
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.sl-seg-btn.is-active {
background: rgba(5, 203, 99, 0.22);
color: var(--text-primary);
}
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
@@ -249,20 +291,10 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
outline-offset: 3px;
}
/* The value and its note share a line: ANY DATE (by upload date). */
.sl-since-value {
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 6px;
min-width: 0;
}
.sl-since-note {
grid-column: 2;
font-family: var(--font-mono);
font-size: 0.5rem;
letter-spacing: 0.08em;
font-size: var(--sl-text-small);
color: var(--text-secondary);
}
@@ -271,6 +303,7 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
+13
View File
@@ -362,9 +362,22 @@ html:not([lang='en']) .rail-card-setting {
font: 8px var(--font-mono);
white-space: nowrap;
}
.rail-card-setting-range {
width: 100%;
min-width: 0;
accent-color: var(--accent);
cursor: pointer;
}
.rail-card-setting-range:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 3px;
}
.rail-card-setting-range-value {
flex: 0 0 4ch;
white-space: nowrap;
color: var(--text-primary);
font: 9px var(--font-mono);
text-align: right;
}
.rail-card-result {
display: grid;
+21 -21
View File
@@ -27,27 +27,27 @@
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
@@ -58,24 +58,24 @@
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
@@ -99,9 +99,9 @@
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
</div>
</div>
</div>
@@ -117,7 +117,7 @@
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
@@ -128,10 +128,10 @@
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
@@ -172,7 +172,7 @@
<span class="pp-label" data-i18n>Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
@@ -182,7 +182,7 @@
<span class="pp-label" data-i18n>Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
+9 -11
View File
@@ -87,9 +87,9 @@
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
</div>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
@@ -118,19 +118,17 @@
<fieldset id="sl-controls" class="sl-controls">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</span>
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
+1 -1
View File
@@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', ()
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
assert.equal(slider.className, 'param-slider');
assert.equal(slider.min, 0);
assert.equal(slider.max, 1);
assert.equal(slider.step, '0.01');
+5 -4
View File
@@ -1,5 +1,4 @@
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
import {
@@ -558,9 +557,11 @@ export class VisualSettings {
const normalized = normalizeAllocationStrategy(raw);
this._detectionAllocationPreference = normalized;
setDetectionTuning({ allocationStrategy: normalized });
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
valueOf: (button) => button.dataset.allocation,
});
for (const button of this._detectionAllocationBtns) {
const active = button.dataset.allocation === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
if (persist) {
try {
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
+2 -1
View File
@@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// requested radius and center authority in descriptions only.
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
// Air quality (ECCC AQHI) extends the manifest.
'775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969',
// Local ADS-B adds altitude quality, observation time and source field hints.
'13595e14cd3837b48ff5a3aa52b287cf98dcf8a47e2aac7ccf9d57bd4d48e51c',
);
});
-496
View File
@@ -1,496 +0,0 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from '../data/manager.js';
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import { controlCctv, createGevActionRunner } from './gevActions.js';
const originalWindow = globalThis.window;
before(() => {
globalThis.window ??= { clearTimeout, setTimeout, requestIdleCallback: null };
});
after(() => {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
});
function viewerFixture() {
return {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
};
}
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
function runnerFixture(t, dataManager, options = {}) {
const runner = createGevActionRunner({
viewer: viewerFixture(),
styleManager: {},
dataManager,
...options,
});
t.after(() => runner.dispose());
return runner;
}
// Only the inert module's lifecycle completion is controlled. The manager's
// intent queue, cancellation, compensation, events, and runner are production.
async function lifecycleFixture(t, targetEnabled) {
const viewer = viewerFixture();
const manager = new DataLayerManager(viewer);
const calls = [];
const gates = [];
const pending = [];
let nextGate = null;
let moduleEnabled = false;
const phase = targetEnabled ? 'enable' : 'disable';
const lifecycle = async (name, options = {}) => {
calls.push(name);
if (name === phase && nextGate) {
const gate = nextGate;
nextGate = null;
gate.entered.resolve(options.signal);
await gate.release.promise;
if (gate.outcome instanceof Error) throw gate.outcome;
if (gate.outcome === false) return false;
}
moduleEnabled = name === 'enable';
return true;
};
manager.register({
id: 'cctv',
name: 'CCTV',
source: 'test',
updateInterval: -1,
init: async () => true,
enable: (_viewer, options) => lifecycle('enable', options),
disable: (_viewer, options) => lifecycle('disable', options),
update: async () => true,
getStats: () => ({ count: 0, lastUpdate: 0 }),
});
if (!targetEnabled)
assert.equal(await manager.setEnabled('cctv', true), true);
calls.length = 0;
const events = [];
const unsubscribe = manager.subscribe((event) => events.push(event));
const runner = runnerFixture(t, manager, { viewer });
t.after(async () => {
for (const gate of gates) gate.release.resolve();
await Promise.allSettled(pending);
unsubscribe();
await manager.destroyAll();
});
return {
manager,
events,
calls,
get moduleEnabled() {
return moduleEnabled;
},
gate(outcome = true) {
const gate = { entered: deferred(), release: deferred(), outcome };
gates.push(gate);
nextGate = gate;
return gate;
},
run(action, options = {}) {
const work = runner('control_cctv', { action }, options);
pending.push(work);
return work;
},
};
}
function assertLifecycle(result, manager, enabled, uncertain = false) {
assert.equal(result.action, 'control_cctv');
assert.equal(result.layerId, 'cctv');
assert.equal(result.enabled, enabled);
assert.equal(result.lifecycleState, enabled ? 'enabled' : 'disabled');
assert.equal(result.lifecycleUncertain, uncertain);
assert.deepEqual(manager.getLayerLifecycleState('cctv'), {
enabled,
lifecycleState: result.lifecycleState,
uncertain,
});
}
const terminalTypes = new Set([
'visibility',
'visibility-cancelled',
'visibility-failed',
'visibility-blocked',
]);
for (const action of ['enable', 'disable']) {
const targetEnabled = action === 'enable';
for (const outcome of [
'success',
'false',
'throw',
'caller-abort',
'resource-abort',
'late-abort',
]) {
test(
`CCTV ${action}: ${outcome} preserves the manager outcome`,
{ timeout: 2000 },
async (t) => {
const h = await lifecycleFixture(t, targetEnabled);
const controller = new AbortController();
const failure =
outcome === 'resource-abort'
? Object.assign(new Error('fixture resource ended'), {
name: 'AbortError',
})
: outcome === 'throw'
? new Error('fixture lifecycle failed')
: null;
const gate = h.gate(failure || (outcome === 'false' ? false : true));
let lateAbortObserved = false;
if (outcome === 'late-abort') {
t.after(
h.manager.subscribe((event) => {
if (event.type === 'visibility' && event.origin === 'voice') {
lateAbortObserved = true;
controller.abort();
}
}),
);
}
let settled = false;
// Uppercase exercises the existing case-insensitive action contract.
const work = h.run(
outcome === 'success' ? action.toUpperCase() : action,
{
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
},
);
void work.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const moduleSignal = await gate.entered.promise;
assert.equal(
settled,
false,
'the tool must wait for lifecycle completion',
);
if (outcome === 'caller-abort') {
controller.abort();
assert.ok(
moduleSignal,
'the manager must receive a lifecycle signal',
);
assert.equal(
moduleSignal.aborted,
true,
'caller abort must reach the manager lifecycle',
);
}
gate.release.resolve();
const result = await work;
const committed = outcome === 'success' || outcome === 'late-abort';
const cancelled =
outcome === 'caller-abort' || outcome === 'resource-abort';
assert.equal(result.ok, committed);
assert.equal(result.cancelled, cancelled ? true : undefined);
assertLifecycle(
result,
h.manager,
committed ? targetEnabled : !targetEnabled,
!targetEnabled && (outcome === 'false' || outcome === 'throw'),
);
assert.equal(
h.moduleEnabled,
committed ? targetEnabled : !targetEnabled,
'the module must match the committed or compensated manager state',
);
if (cancelled) {
assert.equal(result.cancellationReason, outcome);
assert.equal(result.phase, action);
assert.equal(result.successorIntentEpoch, null);
assert.equal(result.successorEnabled, null);
assert.equal(result.successorOrigin, null);
} else if (!committed) {
assert.equal(result.error, `Could not ${action} the requested layer`);
}
const terminal = h.events.filter(
(event) => event.origin === 'voice' && terminalTypes.has(event.type),
);
assert.equal(
terminal.length,
1,
'each voice intent has one terminal manager event',
);
assert.equal(
terminal[0].type,
committed
? 'visibility'
: cancelled
? 'visibility-cancelled'
: 'visibility-failed',
);
if (outcome === 'resource-abort')
assert.equal(controller.signal.aborted, false);
if (outcome === 'late-abort') assert.equal(lateAbortObserved, true);
},
);
}
test(
`CCTV ${action}: already-aborted caller cannot enter lifecycle work`,
{ timeout: 2000 },
async (t) => {
const h = await lifecycleFixture(t, targetEnabled);
const controller = new AbortController();
controller.abort();
const result = await h.run(action, { signal: controller.signal });
assert.equal(result.ok, false);
assert.equal(result.cancelled, true);
assert.equal(result.cancellationReason, 'caller-abort');
assert.equal(result.phase, 'queued');
assertLifecycle(result, h.manager, !targetEnabled);
assert.equal(h.moduleEnabled, !targetEnabled);
assert.deepEqual(h.calls, []);
assert.equal(
h.events.filter((event) => terminalTypes.has(event.type)).length,
1,
);
},
);
}
test(
'CCTV toggles retain missing-module errors without calling visibility',
{ timeout: 2000 },
async (t) => {
for (const layers of [new Map(), new Map([['cctv', {}]])]) {
const runner = runnerFixture(t, {
layers,
getAll: () => [],
setEnabled: () =>
assert.fail('missing CCTV must not dispatch visibility'),
});
for (const action of ['enable', 'disable']) {
assert.deepEqual(await runner('control_cctv', { action }), {
ok: false,
action: 'control_cctv',
error: 'CCTV layer unavailable',
});
}
}
},
);
test('exported controlCctv refuses toggles without calling the manager', async () => {
const manager = {
layers: new Map([['cctv', { module: {} }]]),
setEnabled: () => assert.fail('direct helper must not change visibility'),
isEnabled: () => assert.fail('direct toggle must not read enabled state'),
};
for (const action of ['ENABLE', 'disable']) {
const result = await controlCctv(manager, { action });
assert.equal(result.ok, false);
assert.equal(result.action, 'control_cctv');
assert.match(result.error, /set_layer_visibility/);
}
});
test(
'CCTV fallback managers report rejection, transitional state, and uncertainty',
{ timeout: 2000 },
async (t) => {
for (const enabled of [true, false]) {
const action = enabled ? 'enable' : 'disable';
for (const mode of [
'rejection',
'mismatch',
'transitional',
'uncertain',
]) {
const actualEnabled =
mode === 'rejection' || mode === 'mismatch' ? !enabled : enabled;
const lifecycleState =
mode === 'transitional'
? enabled
? 'enabling'
: 'disabling'
: actualEnabled
? 'enabled'
: 'disabled';
const manager = {
layers: new Map([['cctv', { module: {} }]]),
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
isEnabled: () => actualEnabled,
getLayerLifecycleState: () => ({
enabled: actualEnabled,
lifecycleState,
uncertain: mode === 'uncertain',
}),
async setEnabled() {
if (mode === 'rejection') throw new Error('adapter rejected');
return true;
},
};
const runner = runnerFixture(t, manager);
const result = await runner('control_cctv', { action });
assert.equal(result.ok, false, `${action}: ${mode}`);
assert.equal(result.action, 'control_cctv');
assert.equal(result.enabled, actualEnabled);
assert.equal(result.lifecycleState, lifecycleState);
assert.equal(result.lifecycleUncertain, mode === 'uncertain');
assert.equal(
result.error,
mode === 'rejection'
? 'adapter rejected'
: `Could not ${action} the requested layer`,
);
}
}
},
);
test(
'CCTV visibility composes only its outer speech envelope',
{ timeout: 2000 },
async (t) => {
let enabled = false;
const calls = [];
const runner = runnerFixture(
t,
{
layers: new Map([['cctv', { module: {} }]]),
getAll: () => [{ id: 'cctv', name: 'CCTV' }],
isEnabled: () => enabled,
async setEnabled(_id, value) {
enabled = value;
return true;
},
},
{
speechBuilders: {
control_cctv(result, args) {
calls.push(['cctv', result.action, args.action]);
return {
say: 'CCTV response',
display: { title: 'CCTV', lines: [] },
};
},
set_layer_visibility() {
calls.push(['generic']);
return { say: 'Layer response' };
},
},
},
);
const result = await runner('control_cctv', { action: 'enable' });
assert.equal(result.ok, true);
assert.equal(result.action, 'control_cctv');
assert.equal(result.say, 'CCTV response');
assert.deepEqual(result.display, { title: 'CCTV', lines: [] });
assert.deepEqual(calls, [['cctv', 'control_cctv', 'enable']]);
},
);
test(
'CCTV non-toggle actions retain camera navigation and durable coverage',
{ timeout: 2000 },
async (t) => {
const calls = [];
const cameras = [
{ id: 'a', name: 'Camera A' },
{ id: 'b', name: 'Camera B' },
];
let activeCameraId = 'a';
let coverageMode = 'viewshed';
const module = {
getUIState: () => ({
cameras,
activeCameraId,
activeCamera: cameras.find((camera) => camera.id === activeCameraId),
coverageMode,
showCoverage: coverageMode !== 'off',
}),
selectCamera(id) {
calls.push(['select', id]);
activeCameraId = id;
return true;
},
cycleCamera(step) {
calls.push(['cycle', step]);
activeCameraId = step > 0 ? 'b' : 'a';
return activeCameraId;
},
focusNearest(options) {
calls.push(['nearest', options]);
activeCameraId = 'a';
return 'a';
},
focusCamera(id, duration) {
calls.push(['focus', id, duration]);
return CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW;
},
};
let superseded = 0;
const runner = runnerFixture(
t,
{
layers: new Map([['cctv', { module }]]),
isEnabled: () => true,
setEnabled: () =>
assert.fail('non-toggle action must not dispatch visibility'),
setLayerParams(id, params, options) {
calls.push(['params', id, params, options]);
coverageMode = params.coverageMode;
},
},
{
styleManager: {
supersedeDeferredNavigation() {
superseded += 1;
},
},
},
);
for (const args of [
{ action: 'select', cameraQuery: 'Camera B' },
{ action: 'next' },
{ action: 'prev' },
{ action: 'nearest' },
{ action: 'focus' },
]) {
const result = await runner('control_cctv', args);
assert.equal(result.ok, false);
assert.equal(result.action, 'control_cctv');
assert.match(result.error, /tracking holds the view/);
}
const coverage = await runner('control_cctv', { action: 'coverage' });
assert.equal(coverage.ok, true);
assert.equal(coverage.coverageMode, 'off');
assert.equal(superseded, 5, 'coverage must not acquire camera navigation');
assert.deepEqual(calls, [
['select', 'b'],
['focus', 'b', 1.8],
['cycle', 1],
['focus', 'b', 1.8],
['cycle', -1],
['focus', 'a', 1.8],
['nearest', { focus: false }],
['focus', 'a', 1.8],
['focus', 'a', undefined],
['params', 'cctv', { coverageMode: 'off' }, { origin: 'voice' }],
]);
},
);
+50 -16
View File
@@ -1092,18 +1092,6 @@ export function createGevActionRunner({
}
if (name === 'control_cctv') {
const action = String(args.action || '').toLowerCase();
if (
(action === 'enable' || action === 'disable') &&
dataManager.layers.get('cctv')?.module
) {
const result = await runGevAction(
'set_layer_visibility',
{ layerId: 'cctv', enabled: action === 'enable' },
runOptions,
);
return { ...result, action: 'control_cctv' };
}
return controlCctv(dataManager, args, styleManager);
}
@@ -1444,10 +1432,13 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
}
if (action === 'enable' || action === 'disable') {
await dataManager.setEnabled('cctv', action === 'enable', {
origin: 'voice',
});
return {
ok: false,
ok: true,
action: 'control_cctv',
error: 'Enable or disable CCTV through set_layer_visibility',
enabled: dataManager.isEnabled('cctv'),
};
}
if (!dataManager.isEnabled('cctv')) {
@@ -4452,6 +4443,34 @@ function unwrapProperties(value) {
return out;
}
const LOCAL_ADSB_ALTITUDE_METADATA_KEYS = new Set([
'altitudeQuality',
'altitudeObservedAt',
'altitudeSource',
]);
const LOCAL_ADSB_ALTITUDE_QUALITIES = new Set([
'accepted',
'held-after-rejection',
'feed-estimate',
'inferred-reanchor',
]);
/** Keep the value's provenance, including an explicitly unknown measurement time. */
function localAdsbAltitudeMetadata(record) {
if (
!LOCAL_ADSB_ALTITUDE_QUALITIES.has(record.altitudeQuality) ||
(record.altitudeSource !== 'webusb' && record.altitudeSource !== 'feed') ||
(record.altitudeObservedAt !== null &&
!Number.isFinite(record.altitudeObservedAt))
)
return {};
return {
altitudeQuality: record.altitudeQuality,
altitudeObservedAt: record.altitudeObservedAt,
altitudeSource: record.altitudeSource,
};
}
function compactProperties(props) {
const preferredKeys = [
'name',
@@ -4470,14 +4489,22 @@ function compactProperties(props) {
...props,
...(props.tags && typeof props.tags === 'object' ? props.tags : {}),
};
const result = {};
const result = Number.isFinite(flat.altitudeFt)
? localAdsbAltitudeMetadata(flat)
: {};
for (const key of preferredKeys) {
if (Object.keys(result).length >= 12) break;
const value = cleanText(flat[key]);
if (value) result[key] = value;
}
for (const [key, value] of Object.entries(flat)) {
if (Object.keys(result).length >= 12) break;
if (key === 'tags' || result[key] !== undefined) continue;
if (
key === 'tags' ||
LOCAL_ADSB_ALTITUDE_METADATA_KEYS.has(key) ||
result[key] !== undefined
)
continue;
const text = cleanText(value);
if (text) result[key] = text;
}
@@ -4665,8 +4692,15 @@ function compactAnalystItem(r) {
compact.lon = Math.round(r.lon * 1e4) / 1e4;
}
if (present(r.distanceKm)) compact.distanceKm = r.distanceKm;
if (r.layerKey === 'local-adsb' && Number.isFinite(r.altitudeM))
Object.assign(compact, localAdsbAltitudeMetadata(r));
const fields = voiceLayer(r.layerKey)?.query?.fields || {};
for (const [key, spec] of Object.entries(fields)) {
if (
r.layerKey === 'local-adsb' &&
LOCAL_ADSB_ALTITUDE_METADATA_KEYS.has(key)
)
continue;
if (!present(r[key]) || key in compact) continue;
if (spec.type === 'time') {
const ms = Number(r[key]);
+224
View File
@@ -3931,3 +3931,227 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.ok, true);
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
test('altitude-focused analyst queries retain local ADS-B quality and nullable measurement time', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { clearTimeout, setTimeout, requestIdleCallback: null };
t.after(() => {
globalThis.window = previousWindow;
});
const provenance = [
{
altitudeQuality: 'accepted',
altitudeObservedAt: 100_000,
altitudeSource: 'webusb',
},
{
altitudeQuality: 'held-after-rejection',
altitudeObservedAt: 99_000,
altitudeSource: 'webusb',
},
{
altitudeQuality: 'feed-estimate',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
{
altitudeQuality: 'held-after-rejection',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
{
altitudeQuality: 'inferred-reanchor',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
];
const rows = provenance.map((metadata, index) => ({
id: `LOCAL${index}`,
icao24: `abc12${index}`,
lat: 30,
lon: -97,
altitudeM: 3_048 + index,
...metadata,
altitudeSequence: index,
}));
const module = {
getStats: () => ({ count: rows.length, lastUpdate: Date.now() }),
getAnalystRecords: () => rows,
};
const { runner } = manifestRunner({ 'local-adsb': module });
const result = await runner('analyst_query', {
layers: ['local-adsb'],
scope: { kind: 'anywhere' },
filters: [{ field: 'altitudeM', op: 'gte', value: 3_000 }],
sortBy: 'altitudeM',
sortDir: 'asc',
limit: 5,
});
assert.equal(result.ok, true);
assert.equal(result.count, rows.length);
assert.deepEqual(
result.items.map((item) => item.id),
rows.map((row) => row.id),
);
result.items.forEach((item, index) => {
const { altitudeQuality, altitudeObservedAt, altitudeSource } = item;
assert.deepEqual(
{ altitudeQuality, altitudeObservedAt, altitudeSource },
provenance[index],
);
assert.equal(
Object.hasOwn(item, 'altitudeObservedAt'),
true,
'unknown measurement time remains explicit',
);
assert.equal(
Object.hasOwn(item, 'time'),
false,
'measurement time keeps its own name and epoch-ms unit',
);
assert.equal(
Object.hasOwn(item, 'altitudeSequence'),
false,
'internal observation tokens stay out of the tool payload',
);
});
const heldOnly = await runner('analyst_query', {
layers: ['local-adsb'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'altitudeQuality', op: 'eq', value: 'held-after-rejection' },
],
});
assert.equal(heldOnly.count, 2);
assert.ok(
heldOnly.items.every(
(item) => item.altitudeQuality === 'held-after-rejection',
),
);
const beforeFirstMeasurement = await runner('analyst_query', {
layers: ['local-adsb'],
scope: { kind: 'anywhere' },
filters: [{ field: 'altitudeObservedAt', op: 'lte', value: 1 }],
});
assert.equal(
beforeFirstMeasurement.count,
0,
'unknown feed measurement times do not become epoch zero',
);
rows.splice(0, rows.length, {
id: 'MISSING',
lat: 30,
lon: -97,
altitudeM: null,
...provenance[0],
});
const missing = await runner('analyst_query', {
layers: ['local-adsb'],
scope: { kind: 'anywhere' },
});
assert.equal(missing.items[0].altitudeM, undefined);
for (const key of ['altitudeQuality', 'altitudeObservedAt', 'altitudeSource'])
assert.equal(
Object.hasOwn(missing.items[0], key),
false,
`${key} needs a finite altitude`,
);
});
test('selected local ADS-B context preserves altitude provenance within its 12-property budget', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { clearTimeout, setTimeout, requestIdleCallback: null };
t.after(() => {
globalThis.window = previousWindow;
});
const { viewer, styleManager } = createVoiceNavigationHarness();
// A global view needs no geocoding or nearby-place requests.
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(0, 0, 40_000_000);
viewer.camera.positionCartographic = Cesium.Cartographic.fromCartesian(
viewer.camera.positionWC,
);
viewer.camera.pickEllipsoid = () => undefined;
const dataManager = {
layers: new Map([['local-adsb', { module: {} }]]),
isEnabled: (id) => id === 'local-adsb',
getAll: () => [{ id: 'local-adsb', enabled: true, stats: { count: 1 } }],
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const store = getContextStore();
const provenance = [
{
altitudeQuality: 'accepted',
altitudeObservedAt: 100_000,
altitudeSource: 'webusb',
},
{
altitudeQuality: 'held-after-rejection',
altitudeObservedAt: 99_000,
altitudeSource: 'webusb',
},
{
altitudeQuality: 'feed-estimate',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
{
altitudeQuality: 'held-after-rejection',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
{
altitudeQuality: 'inferred-reanchor',
altitudeObservedAt: null,
altitudeSource: 'feed',
},
];
for (const metadata of provenance) {
const record = registerEntityContext(
{},
{
id: 'local-adsb:abc123',
layerId: 'local-adsb',
label: 'LOCAL1',
latitude: 30,
longitude: -97,
properties: {
icao: 'abc123',
callsign: 'LOCAL1',
category: 'A3',
aircraftClass: 'airliner',
typeCode: 'DC9',
altitudeFt: 10_000,
...metadata,
groundSpeedKt: 180,
trackDeg: 0,
verticalRateFpm: 0,
lastPositionAt: 101_000,
messageCount: 13,
receiverSource: 'webusb',
bands: ['1090'],
sources: ['webusb', 'feed'],
},
},
);
store.selectedEntityId = record.id;
const result = await runner('get_entity_context', { scope: 'selected' });
assert.equal(result.ok, true);
const properties = result.selected.properties;
const { altitudeQuality, altitudeObservedAt, altitudeSource } = properties;
assert.deepEqual(
{ altitudeQuality, altitudeObservedAt, altitudeSource },
metadata,
);
assert.equal(properties.altitudeFt, '10000');
assert.ok(Object.keys(properties).length <= 12);
}
store.entities.get(store.selectedEntityId).properties.altitudeFt = null;
const missing = await runner('get_entity_context', { scope: 'selected' });
for (const key of ['altitudeQuality', 'altitudeObservedAt', 'altitudeSource'])
assert.equal(
Object.hasOwn(missing.selected.properties, key),
false,
`${key} needs a finite altitude`,
);
});
+4
View File
@@ -91,6 +91,10 @@ const layers = [
query: {
fields: {
altitudeM: ['n', 'm'],
// Quality describes the altitude value; feeds have no measurement time.
altitudeQuality: ['t'],
altitudeObservedAt: ['ms'],
altitudeSource: ['t'],
speedKts: ['n', 'kn'],
heading: ['n', 'deg'],
callsign: ['t'],
+11
View File
@@ -112,3 +112,14 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never
claimed.set(alias, entry.id);
}
});
test('local ADS-B analyst fields distinguish altitude quality, time and source', () => {
const fields = VOICE_LAYER_MANIFEST.find((entry) => entry.id === 'local-adsb')
.query.fields;
assert.deepEqual(fields.altitudeQuality, { type: 'text' });
assert.deepEqual(fields.altitudeObservedAt, { type: 'time' });
assert.deepEqual(fields.altitudeSource, { type: 'text' });
assert.ok(ANALYST_LAYERS['local-adsb'].text.includes('altitudeQuality'));
assert.ok(ANALYST_LAYERS['local-adsb'].text.includes('altitudeSource'));
assert.ok(ANALYST_LAYERS['local-adsb'].time.includes('altitudeObservedAt'));
});
-2
View File
@@ -1,6 +1,5 @@
/* Preserve component and responsive rule order in one stylesheet entry. */
@import './src/ui/styles/foundation.css';
@import './src/ui/styles/slider.css';
@import './src/ui/styles/controls.css';
@import './src/ui/styles/location.css';
@import './src/ui/styles/status.css';
@@ -28,5 +27,4 @@
@import './src/ui/styles/weather.css';
@import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/cyber.css';