mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'origin/fix/antimeridian-traffic-bounds'
This commit is contained in:
commit
dadaaf1f27
405 files changed
+84572
-55443
No files matched your search
+101
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## Release disabled infrastructure rendering
|
||||
|
||||
- Remove built Data Center, Dam and Submarine Cable entities when their layers
|
||||
are disabled, avoiding retained visualizer work and entity memory.
|
||||
- Keep parsed datasets cached for re-enable; rebuild entities without refetching.
|
||||
|
||||
## Camera layer components
|
||||
|
||||
- Separate camera source requests, placement, frames, projection, cards and calibration.
|
||||
- Own visibility listeners and pending initialization within each layer lifetime.
|
||||
- Preserve existing camera catalogs, URL families, geometry and playback behavior.
|
||||
|
||||
## Traffic and bikeshare components
|
||||
|
||||
- Separate traffic loading, animation, styling and lifecycle into factory-owned components.
|
||||
- Give each flow source its own bounded decode cache and cancellation checks.
|
||||
- Separate bikeshare registry, station requests, rendering, selection and proximity handling.
|
||||
|
||||
## Installation and context components
|
||||
|
||||
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
|
||||
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
|
||||
- Retain source and ground-floor ownership in standalone composition; reject malformed
|
||||
installation snapshots and ignore failures from cancelled requests.
|
||||
|
||||
## Satellite and mission layer components
|
||||
|
||||
- Separate catalog loading, orbit calculations, display, tracking and interaction
|
||||
into instance-owned satellite components.
|
||||
- Separate mission ingestion, paths, placement, cards, roster, replay and camera
|
||||
operations, retaining existing layer controls and satellite coordination.
|
||||
- Cancel late mission source work and reject malformed launch snapshots.
|
||||
|
||||
## Fire layer components
|
||||
|
||||
- Split fire source loading, state, rendering, cards, selection and viewport work
|
||||
into reusable components with application-owned scene services.
|
||||
- Cancel late refreshes, retain good data after malformed responses, and preserve
|
||||
selection identity without repeating a user-selection notification on refresh.
|
||||
|
||||
## Earthquake components
|
||||
|
||||
- Separate earthquake snapshot loading, record validation, and display ownership.
|
||||
- Cancel pending earthquake refreshes on disable or destruction, retaining the
|
||||
last good snapshot after malformed or failed refreshes.
|
||||
|
||||
## September 8, 2026
|
||||
|
||||
Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering.
|
||||
@@ -13,6 +59,61 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Traffic now retries a failed destination after city navigation without a layer
|
||||
toggle. Camera departure cancels pending work, arrival checks the final view,
|
||||
and superseded requests cannot keep a newer view loading.
|
||||
|
||||
### Added
|
||||
|
||||
- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo.
|
||||
Typing fields retain the key; monitoring stops when hidden.
|
||||
|
||||
|
||||
- Extract vessel feed, store, rendering, selection, trail and card components with explicit source and scene services.
|
||||
- Bound contact retention for incomplete vessel observations, preserve source freshness and refresh history references in place.
|
||||
- Cancel pending vessel history during selection and layer teardown.
|
||||
|
||||
- Split military flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Share the existing aircraft calculations and give military classification an explicit source and cleanup lifecycle. Preserve known military identities even when the source has no position for them.
|
||||
|
||||
- Split civil flights into instance-owned state, ingestion, motion, rendering, tracking and query components. Cancel enrichment on teardown and resolve model assets through the application.
|
||||
|
||||
- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history.
|
||||
- Retain absent aircraft during partially admitted snapshots and bound source error messages.
|
||||
|
||||
- Drive share updates, Location feedback and Scene controls through immutable state snapshots and disposable subscriptions.
|
||||
- Keep stale lookup/load completions from publishing accepted results and retain shot rows during playback progress updates.
|
||||
- Export the existing Scene director with explicit playback and editing outcomes.
|
||||
|
||||
- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners.
|
||||
- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration.
|
||||
- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot.
|
||||
|
||||
- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback.
|
||||
- Preserve shot-label identity on selection so double-click rename can complete.
|
||||
|
||||
- Split Cockpit camera/controller, instruments, briefing, signals and layout into focused modules with explicit application operations.
|
||||
- Give Display portal moves cancellable focus/scroll restoration and stop Cockpit work before asynchronous UI teardown.
|
||||
|
||||
- Separate Context controls, mode transitions and layer restoration; release tab listeners and suppress late panel/search feedback after disposal.
|
||||
|
||||
- Separate camera-panel controls, frame loading, calibration editing and status display; cancel stale image and calibration work on camera changes or disposal.
|
||||
|
||||
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
|
||||
|
||||
- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup.
|
||||
|
||||
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
|
||||
|
||||
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
|
||||
|
||||
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback.
|
||||
|
||||
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
|
||||
|
||||
- Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior.
|
||||
|
||||
- Extract application shortcuts and shader-parameter controls into reusable UI
|
||||
|
||||
@@ -231,3 +231,9 @@ Software runs validate their assertions but do not establish real-GPU visual
|
||||
correctness. Floor-hold retains both mesh and DEM checks: an unavailable mesh
|
||||
oracle fails the run even when the DEM check passes. Record the backend with
|
||||
any screenshots and run GPU visual checks separately when needed.
|
||||
|
||||
FIRMS refactoring can be checked without a configured server key using
|
||||
`node scripts/qa-firms.mjs --url http://localhost:4173 --fixtures`. This explicit
|
||||
fixture mode exercises populated display, aggregation, cards, keyless and stale
|
||||
responses, and selection/camera handoff. It does not establish live-source
|
||||
acceptance; omit `--fixtures` with a configured FIRMS key for that check.
|
||||
@@ -250,3 +250,116 @@ facade's asynchronous teardown can yield.
|
||||
DOM elements and explicit actions, imports no application or effect singleton,
|
||||
and releases every listener on destruction. Settings and rendering remain with
|
||||
the caller.
|
||||
|
||||
## Visual effects
|
||||
|
||||
`ui/effects` exports the effects controller and existing preset definitions.
|
||||
It owns shader stages and their clock, with explicit render ownership callbacks.
|
||||
Construction installs no stages or frame callbacks. Stop animation before
|
||||
releasing UI consumers, then destroy to remove owned stages and restore the
|
||||
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
|
||||
without loading the renderer. UI presentation and product-action coordination
|
||||
remain in their callers.
|
||||
|
||||
## Map Source controls
|
||||
|
||||
`ui/maps` owns source-chip presentation, selection feedback and its subscription
|
||||
lifetime. It receives the existing controller and explicit state/action callbacks;
|
||||
it imports no renderer or application. Source construction and availability policy
|
||||
remain with the map controller. Rebuilding controls removes their previous chip
|
||||
listeners, and destruction suppresses late completions without owning or destroying
|
||||
the supplied controller.
|
||||
|
||||
## Layer panel
|
||||
|
||||
`ui/layers` exports the Layers panel and clear-control binding. Callers supply
|
||||
snapshots, row descriptors, subscriptions and actions; the component imports no
|
||||
layer implementation or application bootstrap. Layer transactions remain with
|
||||
the caller. Hidden-page refresh scheduling remains an explicit callback.
|
||||
|
||||
`ui/layers/feedback` exposes the existing pure loading/notice reducers separately
|
||||
from DOM controls. Scheduling and presentation stay with their callers.
|
||||
|
||||
## Location controls
|
||||
|
||||
`ui/location` exports Location controls, the cancellable lookup controller and
|
||||
the existing location-status formatter. Callers supply city data, search and
|
||||
navigation operations. The component owns DOM listeners and pending expansion;
|
||||
it imports no geocoder, camera engine, layer or application bootstrap. Existing
|
||||
camera authority and search providers remain supplied by the application.
|
||||
|
||||
### Radio controls
|
||||
|
||||
`ui/radio` owns Radio input, disclosures, tuner state and presentation. It
|
||||
receives the existing Radio port and explicit layer/layout actions, without
|
||||
importing the renderer or station providers. Pure tuner calculations retain
|
||||
compatibility exports from the data layer. Disposal revokes DOM listeners and
|
||||
subscriptions before ending the active tuning interaction.
|
||||
|
||||
### Camera panel controls
|
||||
|
||||
`ui/cctv` composes camera controls, frame loading, calibration editing and status
|
||||
presentation. It receives DOM elements, the existing camera port and explicit
|
||||
application actions; it imports no provider, layer or camera engine. Selection,
|
||||
placement, navigation and storage policy remain outside the component family.
|
||||
|
||||
### Context coordination
|
||||
|
||||
`ui/context` owns mode controls, transactions, session restoration and manager
|
||||
subscriptions. Composition supplies the manager, installations search and
|
||||
explicit visual/panel actions. `ui/context/policy` exposes the existing pure
|
||||
mode and restoration rules. No source transport or renderer is imported by
|
||||
these components; initial state and action results retain their existing shape.
|
||||
|
||||
## Cockpit controls
|
||||
|
||||
`ui/cockpit` supplies the Cockpit controller and Display portal. Camera updates,
|
||||
instruments, Context readouts, briefings, signals, layout and input have separate
|
||||
modules. Composition supplies the existing aircraft/awareness operations, terrain
|
||||
cache and sampling operations, continuous-render owner and regional briefing
|
||||
service. Pure math, utility layout and vision helpers have explicit exports.
|
||||
Disposal releases subscriptions and pending work; portal moves preserve the
|
||||
original Display groups, independent scroll positions and current focus owner.
|
||||
|
||||
## Scene controls
|
||||
|
||||
`ui/scenes` owns Scene prompts, panel input, project/shot rows, playback button and runtime
|
||||
presentation. It receives project reads and explicit actions, with no imports of
|
||||
the director, source modules, camera engine or storage. Replacement and disposal
|
||||
release listeners; pending action feedback is limited to its current owner.
|
||||
|
||||
## UI assembly and styles
|
||||
|
||||
`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
|
||||
rendering, HUD and share operations. `src/standalone/ui.js` provides the running
|
||||
application's instances; `src/ui.js` remains the compatibility entry. The shell
|
||||
imports no standalone bootstrap or concrete live layer implementation.
|
||||
Panel layout, position/drag, notices, recording and deferred UI work have separate
|
||||
owners with synchronous cleanup. Existing scene, share and HUD engines retain
|
||||
their entry points. `ui/styles` loads the ordered stylesheet entry; component
|
||||
files retain the original cascade, including responsive and dock refinements.
|
||||
|
||||
## UI state and Scene actions
|
||||
|
||||
`StyleManager.subscribeShareState(listener)` supplies the current shareable
|
||||
visual preferences and subsequent settings changes. The built-in share manager
|
||||
consumes the same updates. `subscribeLocationSearch(listener)` follows the
|
||||
current lookup owner across control replacement. `LocationSearch.subscribe`
|
||||
provides the corresponding per-owner contract. Changes identify `started`,
|
||||
`found`, `missing`, `failed`, `settled`, and the shell's `reset`; request IDs
|
||||
belong to their lookup owner. Only current requests publish accepted results.
|
||||
|
||||
`gods-eye-view/scenes` exports `SceneDirector`. Its `subscribe(listener)` supplies
|
||||
small playback snapshots plus editing outcomes. Scene controls consume these
|
||||
updates to render the affected presentation; progress does not copy the project
|
||||
or rebuild shot rows. Project import/export outcomes include the project;
|
||||
shot editing outcomes include the affected shot and its index before deletion.
|
||||
Camera, layer sequencing, storage and run-file download retain their existing
|
||||
owners. Cesium remains an external dependency supplied by the application.
|
||||
|
||||
Each listener receives `{ state, change, revision, initial }` and subscriptions
|
||||
return an unsubscribe function. Initial state is emitted by default; pass
|
||||
`{ emitCurrent: false }` to receive only changes. Snapshots and outcomes are
|
||||
immutable plain data. Reentrant publications retain delivery order; removing a
|
||||
listener or destroying its owner prevents further queued delivery. These APIs
|
||||
perform no network requests and discover no additional modules.
|
||||
@@ -1,5 +1,259 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Data Centers, Dams and Submarine Cables release their built Cesium data sources
|
||||
and record references when disabled. Parsed datasets remain cached for the layer
|
||||
lifetime, so re-enable rebuilds entities without downloading or parsing again;
|
||||
this can take longer than simply revealing hidden entities. Destruction clears
|
||||
the parsed cache as well. A disable during loading cannot leave a completed
|
||||
build hidden in the scene.
|
||||
|
||||
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
|
||||
frame/media URLs, camera records, ground placement, geometry queues, playback,
|
||||
projection, cards, calibration and interaction have separate components. The
|
||||
standalone entry supplies application-owned scene, ground and activation services.
|
||||
Each layer owns its state and visibility listener; destruction cancels source
|
||||
reads and pending initialization. Malformed health responses retain prior health.
|
||||
Existing catalog fallback, camera poses, frame pacing and coverage controls remain.
|
||||
|
||||
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||
`./layers/bikeshare`. Traffic separates road requests, ingestion, animation,
|
||||
flow matching, styling, viewport lifecycle and development timing. Each source
|
||||
owns its decoded flow cache; cancelled bodies cannot refill it. Bikeshare
|
||||
separates its city registry, station source, parsing, rendering, selection and
|
||||
proximity lifecycle. Existing standalone sources, live/simulated labels, road
|
||||
budgets, station availability and polling behavior are retained.
|
||||
|
||||
Installations and proximity context now expose `./layers/installations` and
|
||||
`./layers/awareness` factories. Each owns its records, selection, presentation,
|
||||
navigation and lifecycle. The standalone entries supply existing scene operations
|
||||
and the aircraft/vessel/installation instances used for proximity queries.
|
||||
Installation requests use a bounded source adapter; explicit nearby-place search
|
||||
remains separate from ordinary mapped-site loading. Invalid snapshots retain the
|
||||
previous display, and cancelled requests cannot publish a later failure state.
|
||||
Viewport limits, saturation retry, placement, query caps and controls are retained.
|
||||
|
||||
Satellite and mission layers expose separate factories through `./layers/satellites`
|
||||
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
||||
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
||||
endpoints; standalone entries provide scene services and the satellite dependency.
|
||||
The mission factory separates paths, replay, camera, panel, overlays and placement.
|
||||
Pending mission requests abort on disable/destruction and malformed launch
|
||||
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
|
||||
tracking intent, replay timing, controls and source attribution remain unchanged.
|
||||
|
||||
The fire layer now exposes `./layers/firms`: an instance factory with separate
|
||||
snapshot requests, records, rendering, cards, selection, viewport scheduling and
|
||||
terrain-anchor batching. The standalone entry supplies the existing FIRMS source
|
||||
and scene services. Disable and teardown cancel pending refreshes; malformed
|
||||
snapshots preserve the last good display. Refresh restoration keeps selection
|
||||
identity without announcing a new user selection. Source/label, LOD thresholds,
|
||||
card limits, fire identities, altitude placement and analyst records are retained.
|
||||
|
||||
Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns
|
||||
its entities and request lifecycle; the application supplies the overlay host
|
||||
and snapshot source. The standalone adapter keeps the existing USGS daily feed,
|
||||
M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or
|
||||
destroying the layer cancels pending work and ignores late results.
|
||||
|
||||
## Vessel components and sources
|
||||
|
||||
`src/data/aisLiveVessels.js` assembles `createVesselLayer` from the
|
||||
`./layers/vessels` package entry. The factory owns feed lifecycle, keyed records,
|
||||
rendering, selection, trails and cards in separate components. The standalone
|
||||
entry supplies its AISStream source, scene services and existing row limits.
|
||||
|
||||
Incomplete observations retain missing keyed vessels for at most five minutes
|
||||
since their last accepted receipt, within the renderer's row budget (plus the
|
||||
existing selected-contact pin). Complete observations keep the existing removal
|
||||
policy. Empty or failed observations preserve warm data with the existing feed
|
||||
health warning and first-connect grace period. Source timestamps remain source
|
||||
timestamps; receipt does not turn an unknown epoch into a fresh update.
|
||||
|
||||
A refreshed record keeps its identity while updating its history reference.
|
||||
Selection changes, disable and destruction cancel pending trail requests; late
|
||||
responses cannot refill a cleared or replacement trail. Heading and course,
|
||||
sea-surface placement, click ownership and card selection policy are unchanged.
|
||||
|
||||
|
||||
## Military-flight components and aircraft mechanics
|
||||
|
||||
`gods-eye-view/layers/military` exports `createMilitaryFlightLayer`. It uses the
|
||||
same normalized observation contract as civil flights, with separate military
|
||||
classification, styling, model and tracking policy. Each instance owns its
|
||||
contacts, history, scratch objects, model loads and cancellation lifetime.
|
||||
Applications supply the existing scene services and resolve model asset URLs;
|
||||
`src/data/militaryFlights.js` keeps the standalone layer API and adsb.lol source.
|
||||
A source may retain a bounded stale-status reason; the standalone cached-feed
|
||||
behavior remains unchanged.
|
||||
|
||||
`gods-eye-view/aircraft` exports the existing shared classification, icon,
|
||||
metadata, motion, altitude, model-anchor, proximity and selection calculations.
|
||||
It also exports `createMilitaryRegistry`, an explicitly constructed owner for
|
||||
known military identities and active-layer transitions. Its optional background
|
||||
poll consumes the optional `getIdentities` capability, falling back to normalized
|
||||
positioned records when a source has no identity-only capability. The adsb.lol
|
||||
adapter preserves known identities without requiring positions; those entries
|
||||
still cannot enter the renderer. Source replacement
|
||||
and disposal abort pending work and clear retained identities; construction
|
||||
starts no network request. Both standalone aircraft layers use one registry.
|
||||
|
||||
## Civil-flight components
|
||||
|
||||
`gods-eye-view/layers/flights` exports `createCivilFlightLayer`. Each instance
|
||||
owns its contacts, histories, model collections, scratch objects and lifecycle.
|
||||
State, ingestion, enrichment, motion/floor interpolation, rendering, tracking and
|
||||
queries live in separate files under `src/layers/flights`. The standalone
|
||||
`src/data/flights.js` assembles the existing OpenSky source and scene services.
|
||||
|
||||
Applications supply the existing floor/snap, geoid, picking, sprite, camera,
|
||||
trail, focus, readout, context, aircraft presentation and render services.
|
||||
The factory never constructs a second application registry. Configure a source
|
||||
before initialization; replacing it while initialized is rejected. Model loads
|
||||
use the supplied asset resolver, including the preload path. Enrichment receives
|
||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||
|
||||
## Browser live-source observations
|
||||
|
||||
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
|
||||
through `gods-eye-view/sources/live`. The standalone adapters use the existing
|
||||
same-origin routes. Aircraft observations distinguish barometric metres from
|
||||
WGS84 ellipsoid metres and retain source position/contact epochs; vessel records
|
||||
retain separate heading/course and sea-surface datum. History is a best-effort
|
||||
addition to the locally accumulated trail, never a promise of complete coverage.
|
||||
|
||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||
the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
Sources receive cancellation signals and check them after body parsing. The
|
||||
layer's existing lifecycle and selection guards continue to reject late work.
|
||||
|
||||
|
||||
## State and action outcomes
|
||||
|
||||
Share preferences, place lookups and Scene playback expose immutable snapshots
|
||||
and disposable subscriptions. Share settings drive URL updates; lookup outcomes
|
||||
drive Location labels and busy/error feedback. Superseded or disposed lookups
|
||||
cannot publish accepted destinations. Each completion carries its own request
|
||||
identity so an older completion cannot clear the current search indicator.
|
||||
|
||||
Scene controls consume playback state and editing outcomes from the director.
|
||||
Progress updates carry a small playback snapshot and preserve shot-row identity;
|
||||
editing outcomes include a copy of the affected scene or shot. Subscriptions
|
||||
start with current state, isolate listener failures and stop on disposal.
|
||||
`gods-eye-view/scenes` exports the same director used by the standalone app.
|
||||
|
||||
## UI shell and component ownership
|
||||
|
||||
The standalone entry composes the UI with the application's existing layer,
|
||||
terrain, navigation and rendering operations. The shell receives those instances
|
||||
and assembles the controls. Panel layout scheduling, position preferences and
|
||||
active drags, loading notices, recording presentation and DOM lookup have focused
|
||||
owners. Disposal revokes queued presentation and listeners before asynchronous
|
||||
Context restoration, cancels an unfinished drag without saving it, restores the
|
||||
recording HUD and releases status decoration without replacing accessible text.
|
||||
The ordinary control snapshot includes the current 3D model toggle and mode.
|
||||
|
||||
`style.css` imports component styles in their original cascade order. Scene,
|
||||
share, HUD and layer engines retain their existing behavior and entry points.
|
||||
|
||||
## Scene control ownership
|
||||
|
||||
Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation
|
||||
and keyboard cancellation. The director supplies project reads and explicit
|
||||
editing/playback actions while retaining persistence, camera and layer sequencing.
|
||||
Shot selection updates the highlight without replacing the row, preserving
|
||||
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
||||
late file and failed-action completions cannot update removed presentation.
|
||||
|
||||
## Cockpit component ownership
|
||||
|
||||
Cockpit presentation is separated from its camera/controller behavior. Existing
|
||||
layer, terrain and rendering operations are supplied by composition, retaining
|
||||
the same tracked identity, ground acquisition, motion correction and cadence.
|
||||
The Display portal owns group anchors, focus/scroll restoration and listeners.
|
||||
Superseded portal frames cannot repaint old state or steal focus after disposal;
|
||||
retained Cockpit actions cannot restart a disposed controller. Input, subscriptions
|
||||
and queued panel work stop before asynchronous layer restoration; final camera
|
||||
and portal cleanup follows that restoration.
|
||||
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
layer snapshots and restoration. The application supplies the existing manager,
|
||||
installations search and camera/panel actions. Tab listeners and pending
|
||||
presentation work stop during disposal; layer restoration retains its existing
|
||||
compensation and latest-intent rules. Clear Selected Layers shares this owner,
|
||||
so an older restore cannot replay over a newer Clear action.
|
||||
|
||||
|
||||
## Camera panel ownership
|
||||
|
||||
CCTV controls receive the existing camera port and explicit application actions.
|
||||
Frame loading, calibration editing and status display have separate modules;
|
||||
providers, camera placement and navigation policy retain their existing owners.
|
||||
Changing cameras invalidates old image callbacks and cancels an unfinished
|
||||
calibration edit. Failed refreshes preserve settled pixels for the same camera,
|
||||
while a newly selected camera never shows the prior camera's image. Disposal
|
||||
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
|
||||
|
||||
|
||||
## UI disposal
|
||||
|
||||
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||
resize observer and window resize listener. These remain independent of the
|
||||
Location component and are included in whole-UI browser teardown acceptance.
|
||||
|
||||
## Radio controls
|
||||
|
||||
Radio panel, compact controls and tuner presentation have a dedicated owner.
|
||||
It receives playback, layer actions and layout callbacks; station ingestion,
|
||||
marker placement, audio playback and camera policy stay with their existing
|
||||
owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
|
||||
## Location control ownership
|
||||
|
||||
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
||||
have a dedicated component with explicit navigation actions. A separate lookup
|
||||
controller cancels superseded searches and checks camera authority before flight
|
||||
and result presentation. Existing search providers and camera handoff policy are
|
||||
preserved. Replacing or closing a POI row cancels its pending expansion frame;
|
||||
destruction releases listeners, pending searches and the orbit indicator.
|
||||
|
||||
|
||||
## Layer panel ownership
|
||||
|
||||
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
|
||||
are owned by a renderer-free panel component. The layer manager supplies current
|
||||
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
|
||||
listeners and row subscriptions; obsolete completions do not repaint old rows.
|
||||
The clear control presents busy state while its existing action owns the transaction.
|
||||
|
||||
|
||||
## Map Source control ownership
|
||||
|
||||
Map Source controls own chip listeners, source-state subscriptions and selection
|
||||
feedback. Loading remains with the supplied map controller. The active chip
|
||||
follows the source actually displayed, including fallback; obsolete completions
|
||||
cannot overwrite a newer selection. Refresh and destruction revoke old listeners
|
||||
and destruction suppresses late UI updates. Available choices and setup behavior
|
||||
remain unchanged.
|
||||
|
||||
## Visual effects ownership
|
||||
|
||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||
destruction removes its stages and restores the borrowed bloom configuration.
|
||||
|
||||
## Display control ownership
|
||||
|
||||
Display button, selector and slider listeners have a single destroyable owner.
|
||||
@@ -3022,3 +3276,30 @@ nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0;
|
||||
image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
|
||||
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
|
||||
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
|
||||
|
||||
### Traffic city navigation
|
||||
|
||||
Traffic checks the final camera view on arrival and retries a failed road request
|
||||
while the camera is stationary, backing off from 1.5 to 30 seconds. Failed road
|
||||
requests report an unavailable source. Leaving the traffic altitude range or
|
||||
disabling the layer cancels pending work; superseded road and flow requests cannot
|
||||
release the current request or keep its loading indicator active.
|
||||
|
||||
### Optional frame-rate readout
|
||||
|
||||
Backtick (`) toggles an FPS readout beneath the title logo. It counts actual
|
||||
Cesium post-render events over one-second windows and does not request extra
|
||||
frames. Typing fields, modified keys and key repeats do not toggle it. The
|
||||
readout starts hidden each session and releases its timer and frame listener
|
||||
when hidden or when the application is disposed.
|
||||
|
||||
|
||||
## Radio components
|
||||
|
||||
The radio entry composes one layer from directory ingestion, station queries,
|
||||
selection, globe rendering and playback modules. Its source supplies directory
|
||||
metadata and click reporting; scene, ground and overlay services are provided
|
||||
explicitly. Each constructed layer owns its catalog, audio and lifecycle state.
|
||||
Existing catalog validation, category filters, tuning and voice playback behavior
|
||||
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||
play action; the source does not relay or record streams.
|
||||
+34
-1
@@ -123,6 +123,39 @@
|
||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.js",
|
||||
"./ui/layout": "./src/ui/panelRails.js",
|
||||
"./ui/input": "./src/ui/visualInput.js",
|
||||
"./ui/display": "./src/ui/displayControls.js"
|
||||
"./ui/display": "./src/ui/displayControls.js",
|
||||
"./ui/effects": "./src/ui/effects.js",
|
||||
"./ui/effects/bloom": "./src/bloom.js",
|
||||
"./ui/maps": "./src/ui/mapSource.js",
|
||||
"./ui/layers": "./src/ui/layers.js",
|
||||
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
||||
"./ui/location": "./src/ui/location.js",
|
||||
"./ui/radio": "./src/ui/radio.js",
|
||||
"./ui/cctv": "./src/ui/cctv.js",
|
||||
"./ui/context": "./src/ui/context.js",
|
||||
"./ui/context/policy": "./src/contextModePolicy.js",
|
||||
"./ui/cockpit": "./src/ui/cockpit.js",
|
||||
"./ui/cockpit/math": "./src/cockpitMath.js",
|
||||
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
|
||||
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js",
|
||||
"./ui/scenes": "./src/ui/scenes.js",
|
||||
"./ui/shell": "./src/ui/applicationShell.js",
|
||||
"./ui/styles": "./src/ui/styles.js",
|
||||
"./scenes": "./src/scenes/director.js",
|
||||
"./sources/live": "./src/sources/live/index.js",
|
||||
"./layers/flights": "./src/layers/flights/index.js",
|
||||
"./layers/military": "./src/layers/military/index.js",
|
||||
"./aircraft": "./src/layers/aircraft/index.js",
|
||||
"./layers/vessels": "./src/layers/vessels/index.js",
|
||||
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
||||
"./layers/firms": "./src/layers/firms/index.js",
|
||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||
"./layers/launches": "./src/layers/launches/index.js",
|
||||
"./layers/installations": "./src/layers/installations/index.js",
|
||||
"./layers/awareness": "./src/layers/awareness/index.js",
|
||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||
"./layers/radio": "./src/layers/radio/index.js"
|
||||
}
|
||||
}
|
||||
+344
-1
@@ -136,5 +136,348 @@
|
||||
"src/ui/visualInput.test.mjs",
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/displayControls.test.mjs"
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualEffects.test.mjs",
|
||||
"src/bloom.js",
|
||||
"src/styles/retro.js",
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/styles/anime.js",
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js",
|
||||
"scripts/qa-visual-effects.mjs",
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/ui/mapSourceControls.test.mjs",
|
||||
"src/mapStackChips.js",
|
||||
"scripts/qa-map-source-controls.mjs",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"scripts/qa-layer-panel.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/locationStatus.js",
|
||||
"src/ui/locationControls.test.mjs",
|
||||
"src/ui/locationSearch.test.mjs",
|
||||
"scripts/qa-location-controls.mjs",
|
||||
"src/ui/radio.js",
|
||||
"src/ui/radioControls.js",
|
||||
"src/ui/radioBindings.js",
|
||||
"src/ui/radioPresentation.js",
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/ui/radioControls.test.mjs",
|
||||
"scripts/qa-ui-disposal.mjs",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvControls.test.mjs",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"scripts/qa-camera-controls.mjs",
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextControls.test.mjs",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitController.test.mjs",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/cockpitDisplayPortal.test.mjs",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
"src/ui/cockpitTrackingController.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/sceneControls.test.mjs",
|
||||
"scripts/qa-scene-controls.mjs",
|
||||
"src/ui.js",
|
||||
"src/standalone/ui.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/ui/styles.js",
|
||||
"src/ui/shellLifecycle.test.mjs",
|
||||
"src/testSupport/readStylesheet.mjs",
|
||||
"src/splitFlap.js",
|
||||
"style.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/command-dock-trays.css",
|
||||
"src/ui/styles/location.css",
|
||||
"src/ui/styles/recording.css",
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/command-dock.css",
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
"src/sources/live/aircraft.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/sources/live/contract.test.mjs",
|
||||
"src/sources/live/index.js",
|
||||
"src/sources/live/standalone.js",
|
||||
"src/sources/live/vessels.js",
|
||||
"src/data/flights.js",
|
||||
"src/testSupport/readLayerSource.mjs",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
"src/layers/flights/controller.js",
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/policy.js",
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/ownership.test.mjs",
|
||||
"src/data/militaryFlights.js",
|
||||
"src/data/militaryRegistry.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/policy.js",
|
||||
"src/layers/military/tracking.js",
|
||||
"src/layers/military/rendering.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/queries.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/military/ownership.test.mjs",
|
||||
"src/layers/aircraft/classification.test.mjs",
|
||||
"src/data/aisLiveVessels.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
"src/layers/vessels/policy.js",
|
||||
"src/layers/vessels/queries.js",
|
||||
"src/layers/vessels/rendering.js",
|
||||
"src/layers/vessels/selection.js",
|
||||
"src/layers/vessels/state.js",
|
||||
"src/layers/vessels/store.js",
|
||||
"src/layers/vessels/testing.js",
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/layers/vessels/ownership.test.mjs",
|
||||
"src/data/earthquakes.js",
|
||||
"src/layers/earthquakes/index.js",
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
"src/layers/earthquakes/ownership.test.mjs",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
"src/layers/firms/ingestion.js",
|
||||
"src/layers/firms/lifecycle.js",
|
||||
"src/layers/firms/model.js",
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
"src/layers/firms/viewport.js",
|
||||
"src/layers/firms/source.test.mjs",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/interaction.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/state.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/tracking.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/testing.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/launches/lifecycle.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/panel.js",
|
||||
"src/layers/launches/source.js",
|
||||
"src/layers/launches/policyHelpers.js",
|
||||
"src/layers/launches/replay.js",
|
||||
"src/layers/launches/launchPad.js",
|
||||
"src/layers/launches/state.js",
|
||||
"src/layers/launches/policy.js",
|
||||
"src/layers/launches/rendering.js",
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/launches/ingestion.js",
|
||||
"src/layers/launches/overlays.js",
|
||||
"src/layers/launches/orbitRendering.js",
|
||||
"src/layers/launches/index.js",
|
||||
"src/layers/launches/paths.js",
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/policy.js",
|
||||
"src/layers/installations/rendering.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/model.js",
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/layers/awareness/lifecycle.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/panel.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/testing.js",
|
||||
"src/layers/awareness/ingestion.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/navigation.js",
|
||||
"src/layers/awareness/index.js",
|
||||
"src/layers/awareness/model.js",
|
||||
"src/layers/awareness/history.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
"src/data/militaryInstallations.js",
|
||||
"src/data/militaryAwareness.js",
|
||||
"src/layers/installations/source.test.mjs",
|
||||
"src/layers/traffic/flow.js",
|
||||
"src/layers/traffic/lifecycle.js",
|
||||
"src/layers/traffic/viewport.js",
|
||||
"src/layers/traffic/controls.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/policy.js",
|
||||
"src/layers/traffic/rendering.js",
|
||||
"src/layers/traffic/ingestion.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/animation.js",
|
||||
"src/layers/traffic/index.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/data/flowTiles.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
"src/layers/bikeshare/source.test.mjs",
|
||||
"scripts/traffic-fixtures.mjs",
|
||||
"src/layers/traffic/navigation.test.mjs",
|
||||
"scripts/qa-traffic-navigation.mjs",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/frameRateMonitor.test.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
"src/layers/cctv/source.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/cards.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/hover.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/index.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/geometryQueue.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/calibration.js",
|
||||
"src/layers/cctv/frames.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/data/cctv.js",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
"src/layers/radio/controls.js",
|
||||
"src/layers/radio/index.js",
|
||||
"src/layers/radio/ingestion.js",
|
||||
"src/layers/radio/interaction.js",
|
||||
"src/layers/radio/labels.js",
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
"src/layers/radio/queries.js",
|
||||
"src/layers/radio/rendering.js",
|
||||
"src/layers/radio/selection.js",
|
||||
"src/layers/radio/source.js",
|
||||
"src/layers/radio/state.js",
|
||||
"src/layers/radio/tuning.js",
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/data/radio.js",
|
||||
"src/layers/radio/source.test.mjs"
|
||||
]
|
||||
@@ -299,5 +299,631 @@
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
||||
"modules": [
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"src/bloom.js",
|
||||
"src/styles/retro.js",
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/styles/anime.js",
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"map-source-controls": {
|
||||
"exports": ["./ui/maps"],
|
||||
"modules": [
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/mapStackChips.js",
|
||||
"src/keySetupCore.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"layer-panel": {
|
||||
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"location-controls": {
|
||||
"exports": ["./ui/location"],
|
||||
"modules": [
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/locationStatus.js",
|
||||
"src/app/stateChannel.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"radio-controls": {
|
||||
"exports": ["./ui/radio"],
|
||||
"modules": [
|
||||
"src/ui/radio.js",
|
||||
"src/ui/radioControls.js",
|
||||
"src/ui/radioBindings.js",
|
||||
"src/ui/radioPresentation.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
"modules": [
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"cockpit-controls": {
|
||||
"exports": [
|
||||
"./ui/cockpit",
|
||||
"./ui/cockpit/math",
|
||||
"./ui/cockpit/layout",
|
||||
"./ui/cockpit/vision"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
"src/ui/cockpitTrackingController.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"scene-controls": {
|
||||
"exports": ["./ui/scenes"],
|
||||
"modules": [
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/bloom.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
"src/cctvFocusRequest.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitTracking.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/locationStatus.js",
|
||||
"src/mapStackChips.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/panelStackLayout.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/splitFlap.js",
|
||||
"src/styles/anime.js",
|
||||
"src/styles/noir.js",
|
||||
"src/styles/retro.js",
|
||||
"src/styles/snow.js",
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/ui/cockpitSignals.js",
|
||||
"src/ui/cockpitTrackingController.js",
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
"src/ui/locationSearch.js",
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/panelLayoutController.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
"src/ui/panelPositionControls.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/radio.js",
|
||||
"src/ui/radioBindings.js",
|
||||
"src/ui/radioControls.js",
|
||||
"src/ui/radioPresentation.js",
|
||||
"src/ui/radioTunerModel.js",
|
||||
"src/ui/recordingControls.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/shellElements.js",
|
||||
"src/ui/shellFeedback.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/visualPresets.js",
|
||||
"src/worldFocus.js",
|
||||
"src/ui/uiLifetime.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/ui/frameRateMonitor.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"ui-styles": {
|
||||
"exports": ["./ui/styles"],
|
||||
"modules": [
|
||||
"src/ui/styles.js",
|
||||
"style.css",
|
||||
"src/ui/styles/foundation.css",
|
||||
"src/ui/styles/controls.css",
|
||||
"src/ui/styles/location.css",
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/recording.css",
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/command-dock.css",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/ui/styles/command-dock-trays.css",
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/provider-settings.css"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"scenes": {
|
||||
"exports": ["./scenes"],
|
||||
"modules": [
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/bloom.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/contextModePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
"modules": [
|
||||
"src/sources/live/aircraft.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/sources/live/index.js",
|
||||
"src/sources/live/standalone.js",
|
||||
"src/sources/live/vessels.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
"src/data/cockpitAirLod.js",
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/flights/controller.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
"src/layers/flights/policy.js",
|
||||
"src/layers/flights/queries.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/state.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/tracking.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
"src/data/cockpitAirLod.js",
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/policy.js",
|
||||
"src/layers/military/queries.js",
|
||||
"src/layers/military/rendering.js",
|
||||
"src/layers/military/state.js",
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"aircraft-mechanics": {
|
||||
"exports": ["./aircraft"],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
"src/data/cockpitAirLod.js",
|
||||
"src/data/cockpitContactDot.js",
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/trackedModelRegime.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/index.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vessel-layer": {
|
||||
"exports": ["./layers/vessels"],
|
||||
"modules": [
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
"src/layers/vessels/policy.js",
|
||||
"src/layers/vessels/queries.js",
|
||||
"src/layers/vessels/rendering.js",
|
||||
"src/layers/vessels/selection.js",
|
||||
"src/layers/vessels/state.js",
|
||||
"src/layers/vessels/store.js",
|
||||
"src/layers/vessels/testing.js",
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/vesselLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"earthquakes": {
|
||||
"exports": ["./layers/earthquakes"],
|
||||
"modules": [
|
||||
"src/layers/earthquakes/index.js",
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"fire-layer": {
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsLabels.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/layers/firms/cards.js",
|
||||
"src/layers/firms/index.js",
|
||||
"src/layers/firms/ingestion.js",
|
||||
"src/layers/firms/lifecycle.js",
|
||||
"src/layers/firms/model.js",
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
"src/layers/firms/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"satellites-layer": {
|
||||
"exports": ["./layers/satellites"],
|
||||
"modules": [
|
||||
"src/data/issPass.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
"src/layers/satellites/interaction.js",
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/state.js",
|
||||
"src/layers/satellites/testing.js",
|
||||
"src/layers/satellites/tracking.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
"launches-layer": {
|
||||
"exports": ["./layers/launches"],
|
||||
"modules": [
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/index.js",
|
||||
"src/layers/launches/ingestion.js",
|
||||
"src/layers/launches/launchPad.js",
|
||||
"src/layers/launches/lifecycle.js",
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/orbitRendering.js",
|
||||
"src/layers/launches/overlays.js",
|
||||
"src/layers/launches/panel.js",
|
||||
"src/layers/launches/paths.js",
|
||||
"src/layers/launches/policy.js",
|
||||
"src/layers/launches/policyHelpers.js",
|
||||
"src/layers/launches/rendering.js",
|
||||
"src/layers/launches/replay.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/source.js",
|
||||
"src/layers/launches/state.js",
|
||||
"src/layers/launches/testing.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"installations-layer": {
|
||||
"exports": ["./layers/installations"],
|
||||
"modules": [
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/model.js",
|
||||
"src/layers/installations/policy.js",
|
||||
"src/layers/installations/rendering.js",
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/layers/awareness/history.js",
|
||||
"src/layers/awareness/index.js",
|
||||
"src/layers/awareness/ingestion.js",
|
||||
"src/layers/awareness/lifecycle.js",
|
||||
"src/layers/awareness/model.js",
|
||||
"src/layers/awareness/navigation.js",
|
||||
"src/layers/awareness/panel.js",
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
"src/layers/awareness/testing.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"traffic-layer": {
|
||||
"exports": ["./layers/traffic"],
|
||||
"modules": [
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/trafficBounds.js",
|
||||
"src/data/trafficFlowStyle.js",
|
||||
"src/data/trafficPresetStyle.js",
|
||||
"src/data/trafficQueue.js",
|
||||
"src/layers/traffic/animation.js",
|
||||
"src/layers/traffic/controls.js",
|
||||
"src/layers/traffic/flow.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/index.js",
|
||||
"src/layers/traffic/ingestion.js",
|
||||
"src/layers/traffic/lifecycle.js",
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/policy.js",
|
||||
"src/layers/traffic/rendering.js",
|
||||
"src/layers/traffic/source.js",
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
"bikeshare-layer": {
|
||||
"exports": ["./layers/bikeshare"],
|
||||
"modules": [
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"cctv-layer": {
|
||||
"exports": ["./layers/cctv"],
|
||||
"modules": [
|
||||
"src/data/cctvCards.js",
|
||||
"src/data/cctvGizmo.js",
|
||||
"src/data/cctvLod.js",
|
||||
"src/data/cctvViewshed.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/trackingClickGesture.js",
|
||||
"src/layers/cctv/calibration.js",
|
||||
"src/layers/cctv/cards.js",
|
||||
"src/layers/cctv/catalog.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
"src/layers/cctv/frames.js",
|
||||
"src/layers/cctv/geometry.js",
|
||||
"src/layers/cctv/geometryQueue.js",
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/layers/cctv/hover.js",
|
||||
"src/layers/cctv/index.js",
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/overlays/worldOverlayTokens.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"radio-layer": {
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
"src/layers/radio/controls.js",
|
||||
"src/layers/radio/index.js",
|
||||
"src/layers/radio/ingestion.js",
|
||||
"src/layers/radio/interaction.js",
|
||||
"src/layers/radio/labels.js",
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
"src/layers/radio/queries.js",
|
||||
"src/layers/radio/rendering.js",
|
||||
"src/layers/radio/selection.js",
|
||||
"src/layers/radio/source.js",
|
||||
"src/layers/radio/state.js",
|
||||
"src/layers/radio/tuning.js",
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/** Camera-panel browser acceptance with controlled sources, images and terrain. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const selectAllModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
const sources = [
|
||||
{
|
||||
id: 'qa-camera-a',
|
||||
name: 'QA Camera A',
|
||||
lat: 30.2747,
|
||||
lon: -97.7403,
|
||||
headingDeg: 30,
|
||||
},
|
||||
{
|
||||
id: 'qa-camera-b',
|
||||
name: 'QA Camera B',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 200,
|
||||
},
|
||||
].map((camera) => ({
|
||||
...camera,
|
||||
city: 'Austin',
|
||||
cityId: 'austin',
|
||||
provider: 'QA camera fixture',
|
||||
feedType: 'snapshot',
|
||||
feedConfigured: true,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 60,
|
||||
rangeM: 300,
|
||||
mountHeightM: 8,
|
||||
}));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail) => {
|
||||
console.log(
|
||||
`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`,
|
||||
);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
let delayCameraA = false;
|
||||
const delayedFrames = new Set();
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.origin !== new URL(origin).origin) return void request.continue();
|
||||
const json = (body) =>
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (url.pathname === '/api/cctv/sources') return void json({ sources });
|
||||
if (url.pathname === '/api/cctv/health')
|
||||
return void json({
|
||||
cameras: sources.map((camera) => ({
|
||||
id: camera.id,
|
||||
status: 'ok',
|
||||
sourceKind: 'snapshot',
|
||||
label: 'QA camera fixture',
|
||||
})),
|
||||
});
|
||||
if (url.pathname.startsWith('/api/cctv/frame/')) {
|
||||
const cameraA = url.pathname.endsWith('qa-camera-a');
|
||||
const respond = () =>
|
||||
request
|
||||
.respond({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
body: `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${cameraA ? '#16364d' : '#254b32'}"/><path d="M0 260L640 130M240 0L420 360" stroke="#97acb7" stroke-width="26"/><text x="30" y="55" fill="white" font-size="30">QA CAMERA ${cameraA ? 'A' : 'B'}</text></svg>`,
|
||||
})
|
||||
.catch(() => {});
|
||||
if (cameraA && delayCameraA) delayedFrames.add(respond);
|
||||
else void respond();
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/terrain/heights') {
|
||||
const points = (url.searchParams.get('points') || '')
|
||||
.split(';')
|
||||
.filter(Boolean);
|
||||
return void json({
|
||||
results: points.map((point) => {
|
||||
const [lon, lat] = point.split(',').map(Number);
|
||||
return { lon, lat, elevation: 0, geoid: 0, ellipsoid: 0 };
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/openai/hud-summary')
|
||||
return void json({ summary: 'QA camera controls' });
|
||||
void request.continue();
|
||||
});
|
||||
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._cctvControls &&
|
||||
window.__godsEyeView?.dataManager?.layers?.has('cctv') &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.$eval('[data-collapse-target="cctv-panel"]', (button) =>
|
||||
button.click(),
|
||||
);
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
|
||||
.cameras.length === 2 &&
|
||||
!document.getElementById('cctv-camera-select').disabled,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
check(
|
||||
'CCTV enable uses the supplied two-camera catalog',
|
||||
await page.$eval(
|
||||
'#cctv-camera-select',
|
||||
(select) => select.options.length === 2 && !select.disabled,
|
||||
),
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'qa-camera-a');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('cctv-frame').dataset.cameraId ===
|
||||
'qa-camera-a' &&
|
||||
document
|
||||
.getElementById('cctv-frame-wrap')
|
||||
.classList.contains('has-frame'),
|
||||
);
|
||||
const state = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState(),
|
||||
);
|
||||
check(
|
||||
'selected camera, decoded preview and source label agree',
|
||||
await page.evaluate(() => {
|
||||
const camera = window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
return (
|
||||
camera.id === 'qa-camera-a' &&
|
||||
document.getElementById('cctv-frame').src.includes('qa-camera-a') &&
|
||||
document
|
||||
.getElementById('cctv-meta')
|
||||
.textContent.includes('QA camera fixture')
|
||||
);
|
||||
}),
|
||||
);
|
||||
const coverage = (await state()).coverageMode;
|
||||
const modes = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.click('#cctv-coverage-btn');
|
||||
modes.push((await state()).coverageMode);
|
||||
}
|
||||
check(
|
||||
'coverage cycles through all three modes and returns to its starting state',
|
||||
new Set(modes).size === 3 && modes.at(-1) === coverage,
|
||||
modes,
|
||||
);
|
||||
const projection = (await state()).showProjection;
|
||||
await page.click('#cctv-projection-btn');
|
||||
check(
|
||||
'projection control updates the layer state',
|
||||
(await state()).showProjection === !projection,
|
||||
);
|
||||
await page.click('#cctv-projection-btn');
|
||||
await page.click('#cctv-next-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
|
||||
.activeCameraId === 'qa-camera-b',
|
||||
);
|
||||
await page.click('#cctv-prev-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
|
||||
.activeCameraId === 'qa-camera-a',
|
||||
);
|
||||
check(
|
||||
'native Next and Previous return to the original camera',
|
||||
(await state()).activeCameraId === 'qa-camera-a',
|
||||
);
|
||||
for (const [selector, key] of [
|
||||
['#cctv-auto-hop-btn', 'autoHop'],
|
||||
['#cctv-adjust-btn', 'calibrationMode'],
|
||||
]) {
|
||||
const before = (await state())[key];
|
||||
await page.click(selector);
|
||||
const changed = (await state())[key] === !before;
|
||||
await page.click(selector);
|
||||
check(
|
||||
`${key} control changes and restores the layer setting`,
|
||||
changed && (await state())[key] === before,
|
||||
);
|
||||
}
|
||||
// Auto Hop can legitimately advance immediately; restore the calibration target.
|
||||
await page.select('#cctv-camera-select', 'qa-camera-a');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState()
|
||||
.activeCameraId === 'qa-camera-a',
|
||||
);
|
||||
const heading = (await state()).activeCamera.headingDeg;
|
||||
const chip = '.cctv-cal-value[data-cal-field="heading"]';
|
||||
await page.click(chip);
|
||||
await page.focus(`${chip} input`);
|
||||
await page.keyboard.down(selectAllModifier);
|
||||
await page.keyboard.press('a');
|
||||
await page.keyboard.up(selectAllModifier);
|
||||
await page.keyboard.type(String(heading + 10));
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'native calibration editing commits an offset for the selected camera',
|
||||
Math.abs((await state()).activeCamera.headingDeg - (heading + 10)) < 0.01,
|
||||
);
|
||||
await page.click('#cctv-calib-save-btn');
|
||||
check(
|
||||
'Save persists the selected camera calibration',
|
||||
await page.evaluate(() =>
|
||||
Boolean(
|
||||
JSON.parse(
|
||||
localStorage.getItem('godsEyeView.cctv.calibration.v2') || '{}',
|
||||
)['qa-camera-a'],
|
||||
),
|
||||
),
|
||||
);
|
||||
await page.click('#cctv-calib-reset-btn');
|
||||
check(
|
||||
'Reset restores the original pose',
|
||||
Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
|
||||
);
|
||||
await page.click(chip);
|
||||
await page.focus(`${chip} input`);
|
||||
await page.keyboard.down(selectAllModifier);
|
||||
await page.keyboard.press('a');
|
||||
await page.keyboard.up(selectAllModifier);
|
||||
await page.keyboard.type('110');
|
||||
await page.keyboard.press('Escape');
|
||||
check(
|
||||
'Escape cancels editing without collapsing CCTV',
|
||||
(await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.cctv-cal-input') &&
|
||||
!document.getElementById('cctv-panel').classList.contains('collapsed'),
|
||||
)) && Math.abs((await state()).activeCamera.headingDeg - heading) < 0.01,
|
||||
);
|
||||
await page.click(chip);
|
||||
await page.evaluate(() => {
|
||||
const input = document.querySelector('.cctv-cal-input');
|
||||
input.value = '110';
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.selectCamera('qa-camera-b');
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
check(
|
||||
'an external camera selection cancels the prior calibration editor',
|
||||
(await state()).activeCamera.id === 'qa-camera-b' &&
|
||||
Math.abs((await state()).activeCamera.headingDeg - 200) < 0.01 &&
|
||||
(await page.$('.cctv-cal-input')) === null,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('cctv-frame').dataset.cameraId ===
|
||||
'qa-camera-b' &&
|
||||
document
|
||||
.getElementById('cctv-frame-wrap')
|
||||
.classList.contains('has-frame'),
|
||||
);
|
||||
await page.setCacheEnabled(false);
|
||||
delayCameraA = true;
|
||||
await page.select('#cctv-camera-select', 'qa-camera-a');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('cctv-frame').dataset.cameraId ===
|
||||
'qa-camera-a' &&
|
||||
document.getElementById('cctv-frame').dataset.loading === 'true',
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const image =
|
||||
window.__godsEyeView.styleManager._cctvControls._cctvFramePreloader;
|
||||
window.__qaLateCctvImage = image;
|
||||
window.__qaLateCctvImageLoaded = false;
|
||||
image.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
window.__qaLateCctvImageLoaded = true;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
check(
|
||||
'a newly selected camera clears old pixels while its frame loads',
|
||||
await page.$eval(
|
||||
'#cctv-frame-wrap',
|
||||
(element) => !element.classList.contains('has-frame'),
|
||||
),
|
||||
);
|
||||
await page.select('#cctv-camera-select', 'qa-camera-b');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('cctv-frame').dataset.cameraId ===
|
||||
'qa-camera-b' &&
|
||||
document
|
||||
.getElementById('cctv-frame-wrap')
|
||||
.classList.contains('has-frame'),
|
||||
);
|
||||
delayCameraA = false;
|
||||
await Promise.all([...delayedFrames].map((respond) => respond()));
|
||||
delayedFrames.clear();
|
||||
await page.waitForFunction(() => window.__qaLateCctvImageLoaded);
|
||||
check(
|
||||
'late frames cannot overwrite the latest camera selection',
|
||||
await page.$eval(
|
||||
'#cctv-frame',
|
||||
(image) =>
|
||||
image.dataset.cameraId === 'qa-camera-b' &&
|
||||
image.src.includes('qa-camera-b'),
|
||||
),
|
||||
);
|
||||
delayCameraA = false;
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.dataManager.isEnabled('cctv'),
|
||||
);
|
||||
const disabledCleanly = await page.$eval(
|
||||
'#cctv-frame',
|
||||
(image) => !image.getAttribute('src') && image.dataset.cameraId === '',
|
||||
);
|
||||
await page.click('#cctv-enable-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.isEnabled('cctv') &&
|
||||
document
|
||||
.getElementById('cctv-frame-wrap')
|
||||
.classList.contains('has-frame'),
|
||||
);
|
||||
check(
|
||||
'disable clears the preview and re-enable reacquires the camera frame',
|
||||
disabledCleanly,
|
||||
);
|
||||
await page.$eval('#cctv-frame', (image) =>
|
||||
image.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
fs.mkdirSync('qa-shots/camera-controls', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/desktop.png' });
|
||||
await page.evaluate(() => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
camera.setView({
|
||||
orientation: {
|
||||
heading: camera.heading + Math.PI / 3,
|
||||
pitch: -0.6,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' });
|
||||
check(
|
||||
'narrow camera panel remains within the viewport',
|
||||
await page.$eval('#cctv-panel', (panel) => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth + 1;
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'rebuilding Radio preserves the active camera controller and subscription',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
const camera = ui._cctvControls;
|
||||
const cameraId = camera.getState()?.activeCameraId;
|
||||
ui._initRadioPanel();
|
||||
ui._radioControls.connect();
|
||||
return (
|
||||
ui._cctvControls === camera &&
|
||||
!camera.destroyed &&
|
||||
Boolean(camera._cctvUnsubscribe) &&
|
||||
camera.getState()?.activeCameraId === cameraId
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.dispose());
|
||||
check(
|
||||
'CCTV controller is destroyed with the real UI',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager._cctvControls.destroyed,
|
||||
),
|
||||
);
|
||||
check(
|
||||
'camera controls produce no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
errors,
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([...delayedFrames].map((respond) => respond()));
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -94,7 +94,9 @@ const getOpt = (name, dflt) => {
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const APP_URL = getOpt('--url', BASE_URL);
|
||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
||||
CCTV_URL.searchParams.set('welcome', '0');
|
||||
const APP_URL = CCTV_URL.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ try {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Space Missions is toggled by the Context cancellation checks below.
|
||||
// Its catalog is unrelated to aircraft/Cockpit behavior; keep the network
|
||||
// error gate meaningful without depending on Launch Library availability.
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/launches') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// This harness verifies UI/lifecycle behavior, not DEM accuracy. Keep an
|
||||
// unrelated upstream terrain outage out of the rendered interaction gate.
|
||||
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/terrain/heights') {
|
||||
@@ -184,21 +195,21 @@ try {
|
||||
const toasts = [];
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
if (styleManager._contextMode) await styleManager._selectContextMode(null);
|
||||
const contactsResult = await styleManager._selectContextMode('flights');
|
||||
const missionResult = await styleManager._selectContextMode('space-missions');
|
||||
if (styleManager._contextControls._contextMode) await styleManager._contextControls._selectContextMode(null);
|
||||
const contactsResult = await styleManager._contextControls._selectContextMode('flights');
|
||||
const missionResult = await styleManager._contextControls._selectContextMode('space-missions');
|
||||
const switched = {
|
||||
contactsResult,
|
||||
missionResult,
|
||||
mode: styleManager._contextMode,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
missionsEnabled: dataManager.isEnabled('rocket-launches'),
|
||||
toasts: [...toasts],
|
||||
};
|
||||
const exitResult = await styleManager._selectContextMode(null);
|
||||
const exitResult = await styleManager._contextControls._selectContextMode(null);
|
||||
return {
|
||||
...switched,
|
||||
exitResult,
|
||||
modeAfterExit: styleManager._contextMode,
|
||||
modeAfterExit: styleManager._contextControls._contextMode,
|
||||
missionsOffAfterExit: !dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
};
|
||||
} finally {
|
||||
@@ -281,16 +292,16 @@ try {
|
||||
const deadline = performance.now() + 5_000;
|
||||
while (performance.now() < deadline) {
|
||||
if (
|
||||
styleManager._contextModeEntering === null
|
||||
&& styleManager._contextSessionSnapshot === null
|
||||
styleManager._contextControls._contextModeEntering === null
|
||||
&& styleManager._contextControls._contextSessionSnapshot === null
|
||||
&& dataManager.isEnabled(siblingId)
|
||||
&& !dataManager.isEffectivelyEnabled('rocket-launches')
|
||||
) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
timedOut = !(
|
||||
styleManager._contextModeEntering === null
|
||||
&& styleManager._contextSessionSnapshot === null
|
||||
styleManager._contextControls._contextModeEntering === null
|
||||
&& styleManager._contextControls._contextSessionSnapshot === null
|
||||
&& dataManager.isEnabled(siblingId)
|
||||
&& !dataManager.isEffectivelyEnabled('rocket-launches')
|
||||
);
|
||||
@@ -298,8 +309,8 @@ try {
|
||||
exercised: true,
|
||||
transitionResult,
|
||||
timedOut,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEnabled(siblingId),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts,
|
||||
@@ -382,7 +393,7 @@ try {
|
||||
rocketEntry.module.disable = async () => true;
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
const entry = styleManager._selectContextMode('space-missions');
|
||||
const entry = styleManager._contextControls._selectContextMode('space-missions');
|
||||
await updateStarted;
|
||||
const replacement = dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
|
||||
releaseUpdate();
|
||||
@@ -390,15 +401,15 @@ try {
|
||||
const committed = {
|
||||
entryResult,
|
||||
replacementResult,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
|
||||
missionEnabled: dataManager.isEnabled('rocket-launches'),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts: [...toasts],
|
||||
};
|
||||
const exitResult = await styleManager._selectContextMode(null);
|
||||
const exitResult = await styleManager._contextControls._selectContextMode(null);
|
||||
return {
|
||||
exercised: true,
|
||||
...committed,
|
||||
@@ -482,7 +493,7 @@ try {
|
||||
styleManager._showToast = (message) => { toasts.push(String(message)); };
|
||||
try {
|
||||
const entry = styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._selectContextMode(
|
||||
(notificationToken) => styleManager._contextControls._selectContextMode(
|
||||
'space-missions',
|
||||
{ notificationToken },
|
||||
),
|
||||
@@ -498,9 +509,9 @@ try {
|
||||
exercised: true,
|
||||
entryResult,
|
||||
offResult,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEnabled(siblingId),
|
||||
missionEffective: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
toasts,
|
||||
@@ -567,35 +578,35 @@ try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
const voiceState = {
|
||||
voiceOn,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingIsolated: !dataManager.isEffectivelyEnabled(siblingId),
|
||||
};
|
||||
const snapshotBeforeOff = [...(styleManager._contextSessionSnapshot?.enabledLayerIds || [])];
|
||||
const snapshotBeforeOff = [...(styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds || [])];
|
||||
const voiceOff = await dataManager.setEnabled('rocket-launches', false, { origin: 'voice' });
|
||||
const reactionCountAfterOff = styleManager._contextLayerReactionPromises.size;
|
||||
const reactionCountAfterOff = styleManager._contextControls._contextLayerReactionPromises.size;
|
||||
await styleManager._waitForContextLayerSettlement();
|
||||
const restoredSiblingEntry = dataManager.layers.get(siblingId);
|
||||
const voiceExit = {
|
||||
voiceOff,
|
||||
snapshotBeforeOff,
|
||||
reactionCountAfterOff,
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
siblingRestored: dataManager.isEffectivelyEnabled(siblingId),
|
||||
siblingEnabled: restoredSiblingEntry?.enabled,
|
||||
siblingLifecycle: restoredSiblingEntry?.lifecycleState,
|
||||
restoreActive: Boolean(styleManager._contextRestoreState),
|
||||
restoreActive: Boolean(styleManager._contextControls._contextRestoreState),
|
||||
};
|
||||
|
||||
await dataManager.setEnabled('rocket-launches', true, { origin: 'programmatic' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const internalState = {
|
||||
mode: styleManager._contextMode,
|
||||
entering: styleManager._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextSessionSnapshot),
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
entering: styleManager._contextControls._contextModeEntering,
|
||||
snapshotRetained: Boolean(styleManager._contextControls._contextSessionSnapshot),
|
||||
missionEnabled: dataManager.isEffectivelyEnabled('rocket-launches'),
|
||||
};
|
||||
await dataManager.setEnabled('rocket-launches', false, { origin: 'programmatic' });
|
||||
@@ -707,7 +718,7 @@ try {
|
||||
const blockerEntry = dataManager.layers.get(blockerId);
|
||||
blockerEntry.initialized = true;
|
||||
await dataManager.setEnabled(blockerId, true, { origin: 'programmatic' });
|
||||
const transition = styleManager._selectContextMode('flights');
|
||||
const transition = styleManager._contextControls._selectContextMode('flights');
|
||||
const disableStart = await settleWithin(disableStarted, gateTimeoutMs);
|
||||
if (!disableStart.settled) {
|
||||
releaseDisable();
|
||||
@@ -725,7 +736,7 @@ try {
|
||||
styleManager.cockpitView.syncEntry();
|
||||
const entry = document.getElementById('cockpit-entry');
|
||||
const pending = {
|
||||
changing: styleManager._contextModeChanging,
|
||||
changing: styleManager._contextControls._contextModeChanging,
|
||||
entryHidden: Boolean(entry?.hidden),
|
||||
enterResult: styleManager.cockpitView.enter(),
|
||||
trackerPreserved: viewer.trackedEntity === trackedEntityBefore,
|
||||
@@ -763,7 +774,7 @@ try {
|
||||
// probe must not poison the checks that run after it.
|
||||
releaseDisable();
|
||||
if (!deferCleanup && dataManager.layers.has(blockerId)) {
|
||||
if (styleManager._contextSessionSnapshot) await styleManager._selectContextMode(null);
|
||||
if (styleManager._contextControls._contextSessionSnapshot) await styleManager._contextControls._selectContextMode(null);
|
||||
await window.__gevQaUnregisterLayer(dataManager, blockerId);
|
||||
styleManager.cockpitView.syncEntry();
|
||||
}
|
||||
@@ -872,7 +883,7 @@ try {
|
||||
let requestSeen = false;
|
||||
let transition = null;
|
||||
try {
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await dataManager.setEnabled('military-installations', false, { origin: 'programmatic' });
|
||||
|
||||
// Hold the first Overpass request open, honouring the module's own
|
||||
@@ -913,7 +924,7 @@ try {
|
||||
});
|
||||
};
|
||||
|
||||
transition = styleManager._selectContextMode('flights');
|
||||
transition = styleManager._contextControls._selectContextMode('flights');
|
||||
// The activation promise stays PENDING while the first fetch is out, and
|
||||
// the Contacts panel comes up behind it — that is what a deferred
|
||||
// dependency is for. Verified live on :4272: the panel renders and reads
|
||||
@@ -984,8 +995,8 @@ try {
|
||||
released = true;
|
||||
window.fetch = realFetch;
|
||||
if (transition) await settleWithin(transition, 20_000);
|
||||
if (styleManager._contextMode !== 'flights') {
|
||||
await settleWithin(styleManager._selectContextMode('flights'), 15_000);
|
||||
if (styleManager._contextControls._contextMode !== 'flights') {
|
||||
await settleWithin(styleManager._contextControls._selectContextMode('flights'), 15_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1060,7 +1071,7 @@ try {
|
||||
return {
|
||||
before: before ? { layerId: before.layerId, id: before.id } : null,
|
||||
after: after ? { layerId: after.layerId, id: after.id } : null,
|
||||
mode: styleManager._contextMode,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
released,
|
||||
refocused,
|
||||
trackedId: trackedInfo?.icao24 || trackedInfo?.id || null,
|
||||
@@ -1206,9 +1217,9 @@ try {
|
||||
// Start from outside both, so activation is a real edge.
|
||||
cockpit.exit({ restoreTracking: true });
|
||||
await settle();
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await settle(400);
|
||||
const cleanupSnapshotCleared = styleManager._contextSessionSnapshot === null;
|
||||
const cleanupSnapshotCleared = styleManager._contextControls._contextSessionSnapshot === null;
|
||||
const cleanupBlockerId = '__qa_slow_contacts_sibling__';
|
||||
const cleanupUnregistered = await window.__gevQaUnregisterLayer(
|
||||
window.__godsEyeView.dataManager,
|
||||
@@ -1224,7 +1235,7 @@ try {
|
||||
const overriddenBeforeContacts = styleManager._detectionUserOverridden;
|
||||
|
||||
// 1. Activating Contacts forces the tactical preset on.
|
||||
const contactsOn = await styleManager._selectContextMode('flights');
|
||||
const contactsOn = await styleManager._contextControls._selectContextMode('flights');
|
||||
await settle(400);
|
||||
const afterContactsOn = mode();
|
||||
const afterContactsOnDensity = density();
|
||||
@@ -1254,7 +1265,7 @@ try {
|
||||
await settle();
|
||||
|
||||
// 4. Deactivating Contacts restores the pre-Contacts state.
|
||||
await styleManager._selectContextMode(null);
|
||||
await styleManager._contextControls._selectContextMode(null);
|
||||
await settle(400);
|
||||
const afterContactsOff = mode();
|
||||
const afterContactsOffDensity = density();
|
||||
@@ -1268,7 +1279,7 @@ try {
|
||||
await settle();
|
||||
|
||||
// Leave the session exactly as the following checks expect it.
|
||||
const restoredContacts = await styleManager._selectContextMode('flights');
|
||||
const restoredContacts = await styleManager._contextControls._selectContextMode('flights');
|
||||
await settle(400);
|
||||
const restoredCockpit = await enterCockpit();
|
||||
styleManager._detectionUserOverridden = originalOverridden;
|
||||
@@ -1348,8 +1359,8 @@ try {
|
||||
step,
|
||||
cockpitActive: cockpit.active,
|
||||
bodyCockpit: document.body.classList.contains('cockpit-mode'),
|
||||
contextMode: styleManager._contextMode,
|
||||
contextChanging: styleManager._contextModeChanging,
|
||||
contextMode: styleManager._contextControls._contextMode,
|
||||
contextChanging: styleManager._contextControls._contextModeChanging,
|
||||
subject: context?.subject ? `${context.subject.layerId}:${context.subject.id}` : null,
|
||||
tracked: tracker?.gevTrackedId || null,
|
||||
density: styleManager.getDetectionState().densityPct,
|
||||
|
||||
+69
-29
@@ -24,6 +24,8 @@
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
|
||||
* Add --fixtures for deterministic source/rendering proof without a key;
|
||||
* that mode does not establish live-source acceptance.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
@@ -43,6 +45,7 @@ const getOpt = (name, dflt) => {
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4420');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -157,7 +160,8 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
return page.evaluate(async (tS) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('local-firms', true);
|
||||
@@ -208,8 +212,20 @@ function stalePayload() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic high-density source records for refactoring without a server key. */
|
||||
function freshFixturePayload() {
|
||||
const template = stalePayload();
|
||||
const fires = Array.from({ length: 1600 }, (_, i) => ({
|
||||
...template.fires[i % template.fires.length],
|
||||
lat: 30.1 + (i % 40) * 0.008,
|
||||
lon: -97.9 + Math.floor(i / 40) * 0.008,
|
||||
frp: 5 + (i % 137),
|
||||
}));
|
||||
return { ...template, fetchedAt: Date.now(), stale: false, count: fires.length, fires };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nLive NASA FIRMS Proof (qa-firms)');
|
||||
console.log(FIXTURES ? '\nFIRMS fixture proof (no live-source acceptance)' : '\nLive NASA FIRMS Proof (qa-firms)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
|
||||
try {
|
||||
@@ -221,7 +237,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||
if (!status?.hasKey) {
|
||||
if (!FIXTURES && !status?.hasKey) {
|
||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -243,14 +259,40 @@ async function main() {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = FIXTURES ? 'fixture' : 'live';
|
||||
const stableStalePayload = stalePayload();
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (interceptMode === 'live') { req.continue(); return; }
|
||||
if (url.includes('/api/firms/status')) {
|
||||
req.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(interceptMode === 'keyless'
|
||||
? { hasKey: false }
|
||||
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/firms')) {
|
||||
if (interceptMode === 'keyless') {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(interceptMode === 'fixture' ? freshFixturePayload() : stableStalePayload) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
|
||||
// ── (i) LIVE feed ────────────────────────────────────────────────────────
|
||||
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
|
||||
console.log(FIXTURES ? '(i) FIXTURE — loading synthetic fires through the source adapter...' : '(i) LIVE — loading the fires layer through the cached proxy...');
|
||||
const live = await bootAndEnable(page);
|
||||
{
|
||||
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
|
||||
record('LIVE: >1000 detections, no error, LIVE label', ok,
|
||||
record(`${FIXTURES ? 'FIXTURE' : 'LIVE'}: >1000 detections, no error, LIVE label`, ok,
|
||||
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
|
||||
record('LIVE: /api/firms/status has key + transaction telemetry',
|
||||
if (!FIXTURES) record('LIVE: /api/firms/status has key + quota statistics',
|
||||
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
|
||||
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
|
||||
if (!ok) exitCode = 1;
|
||||
@@ -299,29 +341,7 @@ async function main() {
|
||||
|
||||
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
|
||||
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = 'keyless';
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
if (url.includes('/api/firms/status')) {
|
||||
req.respond({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify(interceptMode === 'keyless'
|
||||
? { hasKey: false }
|
||||
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.includes('/api/firms')) {
|
||||
if (interceptMode === 'keyless') {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
} else {
|
||||
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
interceptMode = 'keyless';
|
||||
|
||||
const keyless = await bootAndEnable(page, { timeoutS: 20 });
|
||||
{
|
||||
@@ -459,6 +479,26 @@ async function main() {
|
||||
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
|
||||
+ `selectedActions=${proof.selectedActionCount}`);
|
||||
if (!actionOk) exitCode = 1;
|
||||
const refresh = await page.evaluate(async () => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('local-firms').module;
|
||||
const before = layer.getSelectedInfo();
|
||||
let selections = 0;
|
||||
const count = () => { selections += 1; };
|
||||
window.addEventListener('gev:entity-selected', count);
|
||||
try {
|
||||
await layer.update();
|
||||
return { before, after: layer.getSelectedInfo(), selections,
|
||||
contextId: window.__gevContextStore.selectedEntityId };
|
||||
} finally {
|
||||
window.removeEventListener('gev:entity-selected', count);
|
||||
}
|
||||
});
|
||||
const refreshOk = Boolean(refresh.before?.id)
|
||||
&& refresh.after?.id === refresh.before.id
|
||||
&& refresh.contextId === refresh.before.id && refresh.selections === 0;
|
||||
record('REFRESH: selected detection survives without another selection event', refreshOk,
|
||||
JSON.stringify(refresh));
|
||||
if (!refreshOk) exitCode = 1;
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const TESTS = [
|
||||
'src/data/renderAltitude.test.mjs',
|
||||
];
|
||||
|
||||
const FLIGHTS = 'src/data/flights.js';
|
||||
const FLIGHTS = 'src/layers/flights/motion.js';
|
||||
const FLOOR = 'src/data/groundFloor.js';
|
||||
const ALT = 'src/data/renderAltitude.js';
|
||||
|
||||
@@ -94,7 +94,7 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the held floor is stretched without bound (no drift limit)',
|
||||
edits: [
|
||||
{ file: FLIGHTS, from: ' <= HELD_FLOOR_MAX_DRIFT_KM)) {', to: ' <= Infinity)) {' },
|
||||
{ file: FLIGHTS, from: '<= HELD_FLOOR_MAX_DRIFT_KM', to: '<= Infinity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,8 +102,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'false && next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -122,8 +122,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: ' if (next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'next.easedM == null && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -159,8 +159,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (next.easedM == null && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
to: ' if (next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)\n && Number.isFinite(effective) && effective < stoodOnM) {',
|
||||
from: 'next.easedM == null && wasHeld && Number.isFinite(stoodOnM)',
|
||||
to: 'next.easedM == null && Number.isFinite(floor) && wasHeld && Number.isFinite(stoodOnM)',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -191,8 +191,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' const closed = Math.min(FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS));',
|
||||
to: ' const closed = 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS);',
|
||||
from: 'FLOOR_EASE_MAX_STEP, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
|
||||
to: 'Infinity, 1 - Math.exp(-dtMs / FLOOR_EASE_TAU_MS),',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -204,7 +204,7 @@ const MUTATIONS = [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' if (state.retiredMs == null) {',
|
||||
to: ' _displayFloorState.delete(icao24);\n if (false) {',
|
||||
to: ' flightState._displayFloorState.delete(icao24);\n if (false) {',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -213,8 +213,8 @@ const MUTATIONS = [
|
||||
edits: [
|
||||
{
|
||||
file: FLIGHTS,
|
||||
from: ' return state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS;',
|
||||
to: ' return false;',
|
||||
from: 'state.retiredMs != null && nowMs - state.retiredMs > FLOOR_SEED_GRACE_MS',
|
||||
to: 'false',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -291,12 +291,13 @@ for (const mut of MUTATIONS) {
|
||||
for (const e of mut.edits) {
|
||||
if (!backups.has(e.file)) backups.set(e.file, read(e.file));
|
||||
const cur = read(e.file);
|
||||
if (!cur.includes(e.from)) {
|
||||
const anchor = new RegExp(e.from.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*'));
|
||||
if (!anchor.test(cur)) {
|
||||
console.log(` [STALE] ${mut.defect} — anchor not found in ${e.file}`);
|
||||
applied = false;
|
||||
break;
|
||||
}
|
||||
write(e.file, cur.replace(e.from, e.to));
|
||||
write(e.file, cur.replace(anchor, () => e.to.trim()));
|
||||
}
|
||||
if (applied) {
|
||||
const r = runTests();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/** Browser proof of Layers panel replacement and subscription ownership. */
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const check = (name, passed) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
};
|
||||
try {
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?._layerPanel,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const results = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const container = manager._toggleContainer;
|
||||
const id = 'qa-panel-lifecycle';
|
||||
let listener = null;
|
||||
let enabled = 0;
|
||||
window.__gevQaRegisterLayer(manager, {
|
||||
id,
|
||||
name: '<b>Literal layer</b>',
|
||||
icon: '◌',
|
||||
source: 'Local fixture',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {
|
||||
enabled++;
|
||||
},
|
||||
disable() {},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats: () => ({ count: 1250, lastUpdate: Date.now(), stale: true }),
|
||||
getRowControls: () => ({ chips: [] }),
|
||||
setRowControlsListener: (callback) => {
|
||||
listener = callback;
|
||||
},
|
||||
});
|
||||
const result = [];
|
||||
try {
|
||||
manager.buildTogglePanel(container);
|
||||
const row = () => container.querySelector(`[data-layer-id="${id}"]`);
|
||||
const first = row().querySelector('.data-toggle-btn');
|
||||
result.push([
|
||||
'descriptor text is literal content',
|
||||
row().querySelector('.data-name').textContent ===
|
||||
'<b>Literal layer</b>' && !row().querySelector('b'),
|
||||
]);
|
||||
result.push([
|
||||
'row subscription is installed',
|
||||
typeof listener === 'function',
|
||||
]);
|
||||
manager.buildTogglePanel(container);
|
||||
first.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'a detached row cannot issue an enable action',
|
||||
enabled === 0 && !manager.isEnabled(id),
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
result.push([
|
||||
'count is presented from the supplied snapshot',
|
||||
row().querySelector('.data-count').textContent === '1.3K',
|
||||
]);
|
||||
result.push([
|
||||
'feed state reflects the settled layer snapshot',
|
||||
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
|
||||
]);
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._clearSelectedLayersBtn.click();
|
||||
result.push([
|
||||
'native clear activation presents busy state',
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
|
||||
!!ui._contextControls._clearSelectedLayersPromise,
|
||||
]);
|
||||
await ui._contextControls._clearSelectedLayersPromise;
|
||||
result.push([
|
||||
'clear settles through the existing layer transaction',
|
||||
!manager.isEnabled(id) &&
|
||||
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
const final = row().querySelector('.data-toggle-btn');
|
||||
manager._layerPanel.destroy();
|
||||
final.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
'destroyed panel releases row subscriptions',
|
||||
listener === null,
|
||||
]);
|
||||
result.push([
|
||||
'destroyed controls cannot change the layer',
|
||||
manager.isEnabled(id),
|
||||
]);
|
||||
} finally {
|
||||
manager._layerPanel?.destroy();
|
||||
manager._layerPanel = null;
|
||||
await window.__gevQaUnregisterLayer(manager, id);
|
||||
manager.buildTogglePanel(container);
|
||||
}
|
||||
result.push([
|
||||
'ordinary layer rows return after reassembly',
|
||||
container.querySelectorAll('.data-toggle-row').length > 10,
|
||||
]);
|
||||
return result;
|
||||
});
|
||||
for (const [name, passed] of results) check(name, passed);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
/** Location UI acceptance; controlled search results isolate UI races from geocoder availability. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
let failures = 0;
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._locationControls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.click('#location-bar-toggle');
|
||||
await page.click('[data-pin-target="location-bar"]');
|
||||
const cityId = await page.$eval(
|
||||
'.location-pill',
|
||||
(node) => node.dataset.locationId,
|
||||
);
|
||||
await page.click('.location-pill');
|
||||
await page.waitForFunction(() =>
|
||||
document.getElementById('poi-row').classList.contains('expanded'),
|
||||
);
|
||||
check(
|
||||
'native city selection highlights and expands its POIs',
|
||||
await page.evaluate(
|
||||
(id) =>
|
||||
window.__godsEyeView.styleManager._activeLocationId === id &&
|
||||
document.querySelector('.location-pill.active')?.dataset.locationId ===
|
||||
id,
|
||||
cityId,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('w');
|
||||
check(
|
||||
'POI keyboard action selects the second landmark',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._activePoiIndex === 1 &&
|
||||
document.querySelector('.poi-pill.active')?.dataset.poiIndex === '1',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'orbit controls and indicator agree',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui.setOrbit(true);
|
||||
const active =
|
||||
ui.orbitController.active &&
|
||||
document.getElementById('orbit-indicator').classList.contains('active');
|
||||
ui.setOrbit(false);
|
||||
return (
|
||||
active &&
|
||||
!ui.orbitController.active &&
|
||||
!document.getElementById('orbit-indicator').classList.contains('active')
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#search-toggle');
|
||||
await page.type('#location-search', 'qwerty');
|
||||
check(
|
||||
'typing in search leaves POI selection unchanged',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
||||
window.__qaSearchRequests = [];
|
||||
window.__qaLocationState = [];
|
||||
window.__godsEyeView.styleManager.subscribeLocationSearch((notification) =>
|
||||
window.__qaLocationState.push(notification),
|
||||
);
|
||||
lookup.search = (query, options) =>
|
||||
new Promise((resolve) =>
|
||||
window.__qaSearchRequests.push({ query, options, resolve }),
|
||||
);
|
||||
document.getElementById('location-search').value = 'First';
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('location-search').value = 'Second';
|
||||
document.getElementById('location-search').focus();
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 2);
|
||||
check(
|
||||
'second native submit aborts the first request and keeps current busy state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__qaSearchRequests[0].options.signal.aborted &&
|
||||
document
|
||||
.getElementById('location-search')
|
||||
.classList.contains('searching'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__qaSearchRequests[1].resolve({
|
||||
label: 'Second landmark, Test city',
|
||||
}),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager._searchedLocationLabel ===
|
||||
'Second landmark, Test city',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__qaSearchRequests[0].resolve({
|
||||
label: 'First landmark, Test city',
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'late result cannot overwrite the current location readout or style',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
ui._searchedLocationLabel === 'Second landmark, Test city' &&
|
||||
ui._locationMiniCity.textContent.includes('Second landmark') &&
|
||||
ui._locationMiniPoi.textContent === 'Test city' &&
|
||||
ui.activeStyle === 'normal' &&
|
||||
!ui._locationSearch.classList.contains('searching')
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'Location subscriptions accept only the current result and retain request identities',
|
||||
await page.evaluate(() => {
|
||||
const seen = window.__qaLocationState;
|
||||
const started = seen.filter(({ change }) => change?.type === 'started');
|
||||
const found = seen.filter(({ change }) => change?.type === 'found');
|
||||
return (
|
||||
seen[0].initial &&
|
||||
started.length === 2 &&
|
||||
found.length === 1 &&
|
||||
found[0].change.requestId === started[1].change.requestId &&
|
||||
found[0].state.destination.label === 'Second landmark, Test city' &&
|
||||
Object.isFrozen(found[0].state.destination)
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'cancelled POI expansion stays closed after a frame',
|
||||
await page.evaluate(async (id) => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._expandPOIRow(id);
|
||||
ui._collapsePOIRow();
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return (
|
||||
!ui._poiRow.classList.contains('expanded') &&
|
||||
!ui._locationBarDivider.classList.contains('visible')
|
||||
);
|
||||
}, cityId),
|
||||
);
|
||||
await page.evaluate(
|
||||
(id) => window.__godsEyeView.styleManager._onCityPillClick(id),
|
||||
cityId,
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
document.getElementById('poi-row').classList.contains('expanded'),
|
||||
);
|
||||
// Finish the camera's existing city flight before inspecting both layouts.
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
for (let index = 0; index < scene.primitives.length; index++) {
|
||||
const primitive = scene.primitives.get(index);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible city tiles settle before screenshots', settled);
|
||||
fs.mkdirSync('qa-shots/location-controls', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/location-controls/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
|
||||
'mobile',
|
||||
);
|
||||
await page.screenshot({ path: 'qa-shots/location-controls/narrow.png' });
|
||||
check(
|
||||
'native reset returns through the existing camera handoff',
|
||||
await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
ui._resetGlobeBtn.click();
|
||||
const pending = ui._globeResetPromise;
|
||||
if (!pending) return false;
|
||||
await pending;
|
||||
return (
|
||||
!ui.orbitController.active && !window.__godsEyeView.viewer.trackedEntity
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'destroyed controls cannot navigate or submit another lookup',
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
const count = window.__qaSearchRequests.length;
|
||||
ui._locationControls.destroy();
|
||||
ui._locationLookup.destroy();
|
||||
const before = ui._navigationGeneration;
|
||||
ui._locationPills.querySelector('button').click();
|
||||
ui._resetGlobeBtn.click();
|
||||
ui._locationSearch.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
return (
|
||||
before === ui._navigationGeneration &&
|
||||
count === window.__qaSearchRequests.length
|
||||
);
|
||||
}),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
/** Browser acceptance for Map Source control lifecycle and truthful selection. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return (
|
||||
window.__godsEyeView?.styleManager?._mapSourceControls &&
|
||||
cover?.classList.contains('hidden') &&
|
||||
Number(getComputedStyle(cover).opacity) === 0
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const controller = manager.mapStackController;
|
||||
const original = controller.setStack;
|
||||
window.__qaMapSelections = [];
|
||||
controller.setStack = function (id) {
|
||||
window.__qaMapSelections.push(id);
|
||||
return original.call(this, id);
|
||||
};
|
||||
manager.setPanelCollapsed('control-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
});
|
||||
await page.click('[data-stack-id="osm"]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
|
||||
'osm',
|
||||
);
|
||||
check(
|
||||
'a native chip click requests OSM exactly once',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__qaMapSelections.length === 1 &&
|
||||
window.__qaMapSelections[0] === 'osm',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'active chip and status follow the displayed OSM source',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
return (
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === 'osm' &&
|
||||
manager._mapStackStatus.textContent ===
|
||||
manager.mapStackController.getState().activeStack.shortLabel
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'the public map action uses the same component and truthful result',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const result = await manager.setMapStack('esri-imagery');
|
||||
const active = manager.mapStackController.getActiveId();
|
||||
return (
|
||||
window.__qaMapSelections.at(-1) === 'esri-imagery' &&
|
||||
manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset
|
||||
.stackId === active &&
|
||||
(result.ok || !!manager.mapStackController.getState().lastError)
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refresh revokes retained old chips',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]');
|
||||
manager._mapSourceControls.refresh();
|
||||
const before = window.__qaMapSelections.length;
|
||||
old.click();
|
||||
return window.__qaMapSelections.length === before;
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'refreshed chips still select once',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
await Promise.resolve();
|
||||
return window.__qaMapSelections.length === before + 1;
|
||||
}),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.styleManager.mapStackController.getActiveId() ===
|
||||
'osm',
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
await manager.setMapStack('photoreal');
|
||||
manager.setPanelCollapsed('control-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
document.querySelector('.map-stack-chip.active')?.focus();
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (-97.7431 * Math.PI) / 180,
|
||||
latitude: (30.2568 * Math.PI) / 180,
|
||||
height: 1200,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -0.85, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible tile content settles before visual captures', settled);
|
||||
fs.mkdirSync('qa-shots/map-source-controls', { recursive: true });
|
||||
await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' });
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('left-panel-stack')?.dataset.layoutMode ===
|
||||
'mobile',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(
|
||||
'control-panel',
|
||||
false,
|
||||
{ persist: false, syncShare: false },
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' });
|
||||
check(
|
||||
'destroyed controls cannot issue requests from retained chips',
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const before = window.__qaMapSelections.length;
|
||||
manager._mapSourceControls.destroy();
|
||||
manager._mapStackChips.querySelector('[data-stack-id="osm"]').click();
|
||||
const result = await manager._mapSourceControls.select('esri-imagery');
|
||||
return result === null && window.__qaMapSelections.length === before;
|
||||
}),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
if (errors.length) console.log(JSON.stringify(errors));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -918,15 +918,15 @@ try {
|
||||
});
|
||||
const waitForQueuedNotice = async (label, timeoutMs = 1000) => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (styleManager._globalStatusNotice?.label !== label
|
||||
while (styleManager._feedback._globalStatusNotice?.label !== label
|
||||
&& performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return styleManager._globalStatusNotice?.label === label;
|
||||
return styleManager._feedback._globalStatusNotice?.label === label;
|
||||
};
|
||||
const baseNow = performance.now();
|
||||
try {
|
||||
styleManager._loadingFeedbackState = {
|
||||
styleManager._feedback._loadingFeedbackState = {
|
||||
phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0,
|
||||
activeIds: [], batchOutcome: null, terminal: null, operation: null,
|
||||
};
|
||||
@@ -946,7 +946,7 @@ try {
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow);
|
||||
styleManager._updateGlobalLoadingFeedback(baseNow + 200);
|
||||
dataManager.getAll = () => [];
|
||||
styleManager._loadingFeedbackEvent = {
|
||||
styleManager._feedback._loadingFeedbackEvent = {
|
||||
type: 'visibility-failed',
|
||||
layerId: 'qa-unrelated-layer',
|
||||
error: new Error('QA offline'),
|
||||
|
||||
+261
-140
@@ -2,7 +2,8 @@
|
||||
/**
|
||||
* Deterministic browser proof for the Radio companion layer.
|
||||
*
|
||||
* Intercepts only `/api/radio/*` with a 750-station fixture and stubs the
|
||||
* Intercepts Radio endpoints with a 750-station fixture, supplies fixed
|
||||
* satellite/context support responses, and stubs the
|
||||
* browser media `play()` primitive. It proves marker scale/culling, dynamic
|
||||
* station-tag filtering, first-click selection, direct-action-only playback, panel/Context
|
||||
* independence, restoration without autoplay, responsive UI, and a clean
|
||||
@@ -25,6 +26,10 @@ const option = (name, fallback) => {
|
||||
};
|
||||
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
|
||||
// has its own acceptance harness and must not intercept those gestures.
|
||||
const RADIO_URL = new URL(APP_URL);
|
||||
RADIO_URL.searchParams.set('welcome', '0');
|
||||
const HEADFUL = args.includes('--headful');
|
||||
|
||||
const chromeCandidates = [
|
||||
@@ -76,6 +81,22 @@ function check(name, ok, detail = '') {
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Native pick buffers and clustered overlay entries settle on rendered frames,
|
||||
// not on wall-clock sleeps, particularly with software rendering.
|
||||
async function settleRadioFrames(page) {
|
||||
await page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let remaining = 4;
|
||||
const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); }
|
||||
else requestAnimationFrame(() => scene.requestRender());
|
||||
});
|
||||
scene.requestRender();
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(APP_URL).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
@@ -87,7 +108,10 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(chrome ? { executablePath: chrome } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle',
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
||||
],
|
||||
@@ -142,6 +166,28 @@ async function main() {
|
||||
let catalogResponseDelayMs = 0;
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
|
||||
// These scenarios exercise Context lifecycle and keyboard ownership, not
|
||||
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
|
||||
// CelesTrak outages cannot invalidate an otherwise clean UI run.
|
||||
if (url.origin === APP_ORIGIN
|
||||
&& ['/api/celestrak/active', '/api/celestrak/starlink'].includes(url.pathname)) {
|
||||
const dense = url.pathname.endsWith('/starlink');
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: (dense ? [
|
||||
'STARLINK-1007',
|
||||
'1 44713U 19074A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
|
||||
'2 44713 53.0000 247.4627 0006703 130.5360 325.0288 15.06000000 12345',
|
||||
] : [
|
||||
'ISS (ZARYA)',
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9004',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49814310 12345',
|
||||
]).join('\n') + '\n',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/radio/stations') {
|
||||
const response = {
|
||||
status: 200,
|
||||
@@ -213,7 +259,7 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
@@ -281,7 +327,7 @@ async function main() {
|
||||
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
||||
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
||||
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
||||
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
|
||||
tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length,
|
||||
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
|
||||
};
|
||||
});
|
||||
@@ -332,19 +378,19 @@ async function main() {
|
||||
const staticResult = module.setTuningStatic(true);
|
||||
const filterBefore = module.getUIState().filter;
|
||||
const filterControl = document.getElementById('radio-filter');
|
||||
const originalPinned = style._radioTunerBandPinnedForNavigation;
|
||||
const originalPool = style._radioTunerPool;
|
||||
style._radioTunerBandPinnedForNavigation = true;
|
||||
const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation;
|
||||
const originalPool = style._radioControls._radioTunerPool;
|
||||
style._radioControls._radioTunerBandPinnedForNavigation = true;
|
||||
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
|
||||
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const rejectedUIFilter = {
|
||||
moduleFilter: module.getUIState().filter,
|
||||
controlFilter: filterControl.value,
|
||||
pinned: style._radioTunerBandPinnedForNavigation,
|
||||
poolIdentityPreserved: style._radioTunerPool === originalPool,
|
||||
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
|
||||
poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool,
|
||||
};
|
||||
style._radioTunerBandPinnedForNavigation = originalPinned;
|
||||
style._radioTunerPool = originalPool;
|
||||
style._radioControls._radioTunerBandPinnedForNavigation = originalPinned;
|
||||
style._radioControls._radioTunerPool = originalPool;
|
||||
const filterResult = module.setFilter('news');
|
||||
const toggleResult = await module.togglePlayback({ origin: 'user' });
|
||||
const directSelect = module.selectStation(stationId, {
|
||||
@@ -571,7 +617,14 @@ async function main() {
|
||||
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
|
||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
|
||||
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
|
||||
await sleep(650);
|
||||
await page.waitForFunction(() => {
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect();
|
||||
const play = document.getElementById('radio-play-btn').getBoundingClientRect();
|
||||
return directory.top >= viewport.top && directory.bottom <= viewport.bottom
|
||||
&& play.top >= viewport.top && play.bottom <= viewport.bottom;
|
||||
}, { timeout: 10_000 });
|
||||
const explicitRevealAfter = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||
@@ -638,6 +691,7 @@ async function main() {
|
||||
viewer.scene.requestRender();
|
||||
}, { lon: spec.lon, height: view.height });
|
||||
await sleep(700);
|
||||
await settleRadioFrames(page);
|
||||
const sample = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
@@ -729,10 +783,10 @@ async function main() {
|
||||
const originalRestoreEnabledLayerIds = dataManager.restoreEnabledLayerIds;
|
||||
const originalIsEnabled = dataManager.isEnabled;
|
||||
const originalIsEffectivelyEnabled = dataManager.isEffectivelyEnabled;
|
||||
const originalClear = styleManager._clearLayersOutsideContextMode;
|
||||
const originalCapture = styleManager._captureContextSessionSnapshot;
|
||||
const originalContextMode = styleManager._contextMode;
|
||||
const originalSnapshot = styleManager._contextSessionSnapshot;
|
||||
const originalClear = styleManager._contextControls._clearLayersOutsideContextMode;
|
||||
const originalCapture = styleManager._contextControls._captureContextSessionSnapshot;
|
||||
const originalContextMode = styleManager._contextControls._contextMode;
|
||||
const originalSnapshot = styleManager._contextControls._contextSessionSnapshot;
|
||||
const originalShowToast = styleManager._showToast;
|
||||
const installations = dataManager.layers.get('military-installations')?.module;
|
||||
const originalSearchNearby = installations?.searchNearby;
|
||||
@@ -743,12 +797,12 @@ async function main() {
|
||||
const semanticFalseMessage = document.getElementById('toast').textContent;
|
||||
|
||||
const enabled = new Set();
|
||||
styleManager._captureContextSessionSnapshot = () => {};
|
||||
styleManager._contextControls._captureContextSessionSnapshot = () => {};
|
||||
const runContextFailure = async (mode, phase, outcome) => {
|
||||
enabled.clear();
|
||||
enabled.add('local-datacenters');
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(enabled),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -768,7 +822,7 @@ async function main() {
|
||||
return true;
|
||||
};
|
||||
const result = await styleManager._runUserFacingContextAction(
|
||||
() => styleManager._selectContextMode(mode),
|
||||
() => styleManager._contextControls._selectContextMode(mode),
|
||||
`${mode} QA transition failed`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -777,8 +831,8 @@ async function main() {
|
||||
phase,
|
||||
outcome,
|
||||
result,
|
||||
visibleMode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
visibleMode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored: enabled.has('local-datacenters'),
|
||||
entryEnabled: enabled.has(entryLayerId),
|
||||
flightsButtonDisabled: document.getElementById('global-context-flights-btn').disabled,
|
||||
@@ -816,8 +870,8 @@ async function main() {
|
||||
for (const outcome of ['false', 'reject']) {
|
||||
enabled.clear();
|
||||
enabled.add('local-datacenters');
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(enabled),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -832,7 +886,7 @@ async function main() {
|
||||
dataManager.setEnabled = async (candidateId, shouldEnable, options = {}) => {
|
||||
if (!failureUsed && candidateId === 'local-datacenters' && !shouldEnable) {
|
||||
failureUsed = true;
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId: candidateId,
|
||||
enabled: shouldEnable,
|
||||
@@ -851,7 +905,7 @@ async function main() {
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
});
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-blocked',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -871,10 +925,10 @@ async function main() {
|
||||
reason,
|
||||
retryReason,
|
||||
toastMessages,
|
||||
mode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored,
|
||||
modeChanging: styleManager._contextModeChanging,
|
||||
modeChanging: styleManager._contextControls._contextModeChanging,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -883,8 +937,8 @@ async function main() {
|
||||
const directActivationFailures = [];
|
||||
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
||||
enabled.clear();
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(['local-datacenters']),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -894,7 +948,7 @@ async function main() {
|
||||
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
||||
return true;
|
||||
};
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -903,8 +957,8 @@ async function main() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
directActivationFailures.push({
|
||||
layerId,
|
||||
mode: styleManager._contextMode,
|
||||
snapshotCleared: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
snapshotCleared: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
priorLayerRestored: enabled.has('local-datacenters'),
|
||||
failedLayerDisabled: !enabled.has(layerId),
|
||||
});
|
||||
@@ -913,8 +967,8 @@ async function main() {
|
||||
const directActivationRollbackFailures = [];
|
||||
for (const layerId of ['military-awareness', 'rocket-launches']) {
|
||||
enabled.clear();
|
||||
styleManager._contextMode = null;
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = null;
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(['local-datacenters']),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -931,7 +985,7 @@ async function main() {
|
||||
shouldEnable ? enabled.add(candidateId) : enabled.delete(candidateId);
|
||||
return true;
|
||||
};
|
||||
styleManager._handleContextLayerChange({
|
||||
styleManager._contextControls._handleContextLayerChange({
|
||||
type: 'visibility-failed',
|
||||
layerId,
|
||||
enabled: true,
|
||||
@@ -942,7 +996,7 @@ async function main() {
|
||||
directActivationRollbackFailures.push({
|
||||
layerId,
|
||||
toastMessages,
|
||||
retryRetained: styleManager._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
|
||||
retryRetained: styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has('local-datacenters') === true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -972,8 +1026,8 @@ async function main() {
|
||||
window.__gevQaRegisterLayer(dataManager, qaLayer);
|
||||
try {
|
||||
const enabledBeforeExit = dataManager.getEnabledLayerIds();
|
||||
styleManager._contextMode = 'flights';
|
||||
styleManager._contextSessionSnapshot = {
|
||||
styleManager._contextControls._contextMode = 'flights';
|
||||
styleManager._contextControls._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set([...enabledBeforeExit, layerId]),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
@@ -985,13 +1039,13 @@ async function main() {
|
||||
};
|
||||
const unhandledBefore = unhandled.length;
|
||||
const result = await styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
||||
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
|
||||
`QA Context exit ${outcome} surfaced once`,
|
||||
);
|
||||
const retainedForRetry = styleManager._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
|
||||
const retainedForRetry = styleManager._contextControls._contextSessionSnapshot?.enabledLayerIds?.has(layerId) === true;
|
||||
enableOutcome = 'success';
|
||||
const retryResult = await styleManager._runUserFacingContextAction(
|
||||
(notificationToken) => styleManager._deactivateContextForLayerChange({ notificationToken }),
|
||||
(notificationToken) => styleManager._contextControls._deactivateContextForLayerChange({ notificationToken }),
|
||||
`QA Context exit ${outcome} retry failed`,
|
||||
);
|
||||
contextExitFailures.push({
|
||||
@@ -1001,9 +1055,9 @@ async function main() {
|
||||
toastMessages,
|
||||
unhandledDelta: unhandled.length - unhandledBefore,
|
||||
retainedForRetry,
|
||||
mode: styleManager._contextMode,
|
||||
modeChanging: styleManager._contextModeChanging,
|
||||
snapshotClearedAfterRetry: styleManager._contextSessionSnapshot === null,
|
||||
mode: styleManager._contextControls._contextMode,
|
||||
modeChanging: styleManager._contextControls._contextModeChanging,
|
||||
snapshotClearedAfterRetry: styleManager._contextControls._contextSessionSnapshot === null,
|
||||
enabledAfterRetry: dataManager.isEnabled(layerId),
|
||||
toastRole: document.getElementById('toast').getAttribute('role'),
|
||||
toastLive: document.getElementById('toast').getAttribute('aria-live'),
|
||||
@@ -1109,10 +1163,10 @@ async function main() {
|
||||
dataManager.restoreEnabledLayerIds = originalRestoreEnabledLayerIds;
|
||||
dataManager.isEnabled = originalIsEnabled;
|
||||
dataManager.isEffectivelyEnabled = originalIsEffectivelyEnabled;
|
||||
styleManager._clearLayersOutsideContextMode = originalClear;
|
||||
styleManager._captureContextSessionSnapshot = originalCapture;
|
||||
styleManager._contextMode = originalContextMode;
|
||||
styleManager._contextSessionSnapshot = originalSnapshot;
|
||||
styleManager._contextControls._clearLayersOutsideContextMode = originalClear;
|
||||
styleManager._contextControls._captureContextSessionSnapshot = originalCapture;
|
||||
styleManager._contextControls._contextMode = originalContextMode;
|
||||
styleManager._contextControls._contextSessionSnapshot = originalSnapshot;
|
||||
styleManager._showToast = originalShowToast;
|
||||
if (installations) installations.searchNearby = originalSearchNearby;
|
||||
window.removeEventListener('unhandledrejection', onUnhandled);
|
||||
@@ -1320,12 +1374,9 @@ async function main() {
|
||||
&& clusterBadge?.rect?.h > 0,
|
||||
JSON.stringify(clusterBadge),
|
||||
);
|
||||
const highGlobalClusterLabels = await page.evaluate(async () => {
|
||||
await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
@@ -1337,6 +1388,14 @@ async function main() {
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
await settleRadioFrames(page);
|
||||
const highGlobalClusterLabels = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||
const source = radio.getOverlayDiagnostics();
|
||||
const cameraPosition = viewer.camera.positionWC;
|
||||
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||
@@ -1347,11 +1406,23 @@ async function main() {
|
||||
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
|
||||
let clusterPoint = null;
|
||||
// Pool order is not visibility order after a camera change. Choose an
|
||||
// on-screen point independently of the native pick result being tested.
|
||||
const visiblePoints = [];
|
||||
for (let index = 0; index < (points?.length || 0); index += 1) {
|
||||
const point = points.get(index);
|
||||
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
|
||||
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
|
||||
if (!anchor) continue;
|
||||
if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue;
|
||||
visiblePoints.push({ point, anchor });
|
||||
}
|
||||
visiblePoints.sort((a, b) => (
|
||||
Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2)
|
||||
- Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2)
|
||||
));
|
||||
if (visiblePoints.length) {
|
||||
const { point, anchor } = visiblePoints[0];
|
||||
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
|
||||
picked?.primitive === point && picked?.id === point.id
|
||||
));
|
||||
@@ -1359,7 +1430,6 @@ async function main() {
|
||||
maxDistance: point.distanceDisplayCondition?.far ?? null,
|
||||
pickable: Boolean(exactPick),
|
||||
};
|
||||
break;
|
||||
}
|
||||
return {
|
||||
source,
|
||||
@@ -1632,8 +1702,8 @@ async function main() {
|
||||
dataset: { ...stack.dataset },
|
||||
}];
|
||||
})),
|
||||
preferredLeftPanelId: manager._leftStackPreferredPanelId,
|
||||
preferredRightPanelId: manager._rightStackPreferredPanelId,
|
||||
preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
|
||||
preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId,
|
||||
hudMode: manager.hud.getMode(),
|
||||
hudVariant: manager.hud.getVariant(),
|
||||
focusId: document.activeElement?.id || null,
|
||||
@@ -1697,7 +1767,7 @@ async function main() {
|
||||
const scenePanel = document.getElementById('scene-panel');
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
result.tacticalAutoCollapse = {
|
||||
latestOpenedPanel: manager._leftStackPreferredPanelId,
|
||||
latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId,
|
||||
scenesExpanded: !scenePanel.classList.contains('collapsed'),
|
||||
dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'),
|
||||
dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0
|
||||
@@ -1744,13 +1814,13 @@ async function main() {
|
||||
manager.hud.setMode(prior.hudMode);
|
||||
manager._updateHudButtonState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
if (manager._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
||||
manager._leftStackLayoutFrame = null;
|
||||
if (manager._panelLayout._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
|
||||
manager._panelLayout._leftStackLayoutFrame = null;
|
||||
}
|
||||
if (manager._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
||||
manager._rightStackLayoutFrame = null;
|
||||
if (manager._panelLayout._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
|
||||
manager._panelLayout._rightStackLayoutFrame = null;
|
||||
}
|
||||
for (const [id, state] of Object.entries(prior.panels)) {
|
||||
const panel = document.getElementById(id);
|
||||
@@ -1769,18 +1839,18 @@ async function main() {
|
||||
for (const key of Object.keys(stack.dataset)) delete stack.dataset[key];
|
||||
Object.assign(stack.dataset, state.dataset);
|
||||
}
|
||||
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
manager._rightStackPreferredPanelId = prior.preferredRightPanelId;
|
||||
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId;
|
||||
localStorage.clear();
|
||||
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
if (manager._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
||||
manager._leftStackLayoutFrame = null;
|
||||
if (manager._panelLayout._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
|
||||
manager._panelLayout._leftStackLayoutFrame = null;
|
||||
}
|
||||
if (manager._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
||||
manager._rightStackLayoutFrame = null;
|
||||
if (manager._panelLayout._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
|
||||
manager._panelLayout._rightStackLayoutFrame = null;
|
||||
}
|
||||
// Disclosure restoration can wake the installed layout observers. Let
|
||||
// those observers settle, then restore the captured stack presentation
|
||||
@@ -1810,8 +1880,8 @@ async function main() {
|
||||
result.restoreDetails = {
|
||||
panelsRestored,
|
||||
stacksRestored,
|
||||
preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId,
|
||||
preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId,
|
||||
preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId,
|
||||
preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId,
|
||||
hudModeRestored: manager.hud.getMode() === prior.hudMode,
|
||||
hudVariantRestored: manager.hud.getVariant() === prior.hudVariant,
|
||||
storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage),
|
||||
@@ -1875,7 +1945,10 @@ async function main() {
|
||||
hudVariant: manager.hud.getVariant(),
|
||||
focusId: document.activeElement?.id || null,
|
||||
};
|
||||
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
||||
const waitForLayout = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const cases = [];
|
||||
|
||||
manager.setPanelCollapsed('global-context-panel', true);
|
||||
@@ -1952,7 +2025,25 @@ async function main() {
|
||||
// owns the real tracked-aircraft camera session. Hold the frame update so
|
||||
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
|
||||
const realCockpitUpdate = manager.cockpitView.update;
|
||||
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
||||
const intelHud = document.getElementById('intel-hud');
|
||||
const priorHudTransition = intelHud.style.getPropertyValue('transition');
|
||||
const priorHudTransitionPriority = intelHud.style.getPropertyPriority('transition');
|
||||
const waitForLayout = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const waitForHudSettle = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 560));
|
||||
// Let panel changes settle. qa-cockpit-utility covers scheduling with
|
||||
// the real tracked-aircraft controller; this synthetic shell pauses it.
|
||||
intelHud.getBoundingClientRect();
|
||||
// This fixture pauses the controller update. Run its actual layout
|
||||
// methods against the settled HUD, then measure both unchanged lanes.
|
||||
manager.cockpitView.syncContextLayout();
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
manager._syncLeftPanelAdaptiveLayout();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const prior = {
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
cockpitActive: manager.cockpitView.active,
|
||||
@@ -1971,7 +2062,7 @@ async function main() {
|
||||
detectionMode: manager.getDetectionState().detectionMode,
|
||||
models3dEnabled: manager._models3dEnabled,
|
||||
models3dMode: manager._models3dMode,
|
||||
preferredLeftPanelId: manager._leftStackPreferredPanelId,
|
||||
preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
|
||||
panels: Object.fromEntries([
|
||||
'data-panel',
|
||||
'global-context-panel',
|
||||
@@ -1989,6 +2080,9 @@ async function main() {
|
||||
let result = {};
|
||||
try {
|
||||
manager.cockpitView.update = () => {};
|
||||
// This block measures settled panel geometry, not fade timing. The
|
||||
// synthetic paused controller cannot advance layout during a HUD fade.
|
||||
intelHud.style.setProperty('transition', 'none', 'important');
|
||||
document.body.classList.add('cockpit-mode');
|
||||
manager.cockpitView.active = true;
|
||||
manager._setCockpitDisplayPortalActive(true);
|
||||
@@ -2002,7 +2096,7 @@ async function main() {
|
||||
manager._setHudVariant('tactical');
|
||||
manager.hud.setMode('on');
|
||||
manager._updateHudButtonState();
|
||||
await waitForLayout();
|
||||
await waitForHudSettle();
|
||||
|
||||
const layoutSteps = [];
|
||||
// The two Cockpit lanes are solved independently: the accordion against
|
||||
@@ -2055,9 +2149,7 @@ async function main() {
|
||||
layoutMode: document.getElementById('left-panel-stack').dataset.layoutMode,
|
||||
});
|
||||
};
|
||||
// The Intel HUD fades over 400ms and keeps its readout rects for the
|
||||
// whole transition, so both lanes are measured only once it has settled.
|
||||
const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560));
|
||||
// Both lanes are measured against the settled HUD variant.
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
recordLayoutStep();
|
||||
if (index < 4) {
|
||||
@@ -2208,19 +2300,23 @@ async function main() {
|
||||
const stableTop = utility.getBoundingClientRect().top;
|
||||
const transitionTops = [];
|
||||
let sampling = true;
|
||||
let sampleFrame = null;
|
||||
const sampleTop = () => {
|
||||
if (!sampling) return;
|
||||
transitionTops.push(utility.getBoundingClientRect().top);
|
||||
if (sampling) requestAnimationFrame(sampleTop);
|
||||
sampleFrame = requestAnimationFrame(sampleTop);
|
||||
};
|
||||
requestAnimationFrame(sampleTop);
|
||||
sampleFrame = requestAnimationFrame(sampleTop);
|
||||
await manager._setMapStack('osm', { syncShare: false });
|
||||
await waitForLayout();
|
||||
sampling = false;
|
||||
cancelAnimationFrame(sampleFrame);
|
||||
mapProviderUtilityStable = transitionTops.length > 1
|
||||
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
|
||||
}
|
||||
result = {
|
||||
cockpitPanelInteraction,
|
||||
layoutSteps,
|
||||
displayOpened,
|
||||
sharedDisplayControlsPortaled,
|
||||
radioOpened,
|
||||
@@ -2269,6 +2365,8 @@ async function main() {
|
||||
manager._setModels3dEnabled(prior.models3dEnabled);
|
||||
manager._setHudVariant(prior.hudVariant);
|
||||
manager.hud.setMode(prior.hudMode);
|
||||
if (priorHudTransition) intelHud.style.setProperty('transition', priorHudTransition, priorHudTransitionPriority);
|
||||
else intelHud.style.removeProperty('transition');
|
||||
manager._updateHudButtonState();
|
||||
manager.cockpitView.active = prior.cockpitActive;
|
||||
manager.cockpitView.update = realCockpitUpdate;
|
||||
@@ -2284,13 +2382,13 @@ async function main() {
|
||||
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
|
||||
else hud.style.removeProperty('--cockpit-utility-top');
|
||||
await waitForLayout();
|
||||
if (manager._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._leftStackLayoutFrame);
|
||||
manager._leftStackLayoutFrame = null;
|
||||
if (manager._panelLayout._leftStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
|
||||
manager._panelLayout._leftStackLayoutFrame = null;
|
||||
}
|
||||
if (manager._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._rightStackLayoutFrame);
|
||||
manager._rightStackLayoutFrame = null;
|
||||
if (manager._panelLayout._rightStackLayoutFrame !== null) {
|
||||
cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
|
||||
manager._panelLayout._rightStackLayoutFrame = null;
|
||||
}
|
||||
for (const [id, state] of Object.entries(prior.panels)) {
|
||||
const panel = document.getElementById(id);
|
||||
@@ -2301,7 +2399,7 @@ async function main() {
|
||||
else panel.setAttribute('aria-hidden', state.ariaHidden);
|
||||
manager._syncPanelCollapseButton(panel);
|
||||
}
|
||||
manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
|
||||
localStorage.clear();
|
||||
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
@@ -2316,6 +2414,8 @@ async function main() {
|
||||
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
|
||||
&& hud.hidden === prior.hudHidden
|
||||
&& signal.hidden === prior.signalHidden
|
||||
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
|
||||
&& intelHud.style.getPropertyPriority('transition') === priorHudTransitionPriority
|
||||
&& manager.hud.getMode() === prior.hudMode
|
||||
&& manager.hud.getVariant() === prior.hudVariant
|
||||
&& Object.entries(prior.panels).every(([id, state]) => {
|
||||
@@ -2324,7 +2424,7 @@ async function main() {
|
||||
&& panel.getAttribute('style') === state.style
|
||||
&& panel.getAttribute('aria-hidden') === state.ariaHidden;
|
||||
})
|
||||
&& manager._leftStackPreferredPanelId === prior.preferredLeftPanelId
|
||||
&& manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId
|
||||
&& JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage)
|
||||
&& (!prior.focusId || document.activeElement?.id === prior.focusId);
|
||||
}
|
||||
@@ -2652,9 +2752,15 @@ async function main() {
|
||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||
await radio.play();
|
||||
const beforeSpace = radio.getUIState().audioState;
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' }));
|
||||
// Simulate an already-started hold-Space session; click-started open mic
|
||||
// deliberately ignores Space takeover, and short taps never claim voice.
|
||||
const priorPushToTalkMode = voice.pushToTalkMode;
|
||||
voice.pushToTalkMode = true;
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
const afterSpace = radio.getUIState().audioState;
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }));
|
||||
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||
voice.pushToTalkMode = priorPushToTalkMode;
|
||||
await radio.play();
|
||||
voice.setVoiceSpeaker('ai');
|
||||
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
||||
@@ -2719,7 +2825,7 @@ async function main() {
|
||||
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
|
||||
const sliderRect = slider.getBoundingClientRect();
|
||||
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
|
||||
const count = style._radioTunerStations.length;
|
||||
const count = style._radioControls._radioTunerStations.length;
|
||||
const usable = sliderRect.width - 14;
|
||||
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
|
||||
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
|
||||
@@ -2745,7 +2851,7 @@ async function main() {
|
||||
};
|
||||
};
|
||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||
const frozenSignature = style._radioTunerBandSignature;
|
||||
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
||||
pointer('pointerdown', xFor(0));
|
||||
const left = snapshot();
|
||||
const centerCoordinate = (count - 1) / 2;
|
||||
@@ -2765,14 +2871,14 @@ async function main() {
|
||||
visible: !document.getElementById('radio-tuner').hidden,
|
||||
stationCount: count,
|
||||
sliderMax: Number(slider.max),
|
||||
bandFrozen: style._radioTunerBandSignature === frozenSignature,
|
||||
bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature,
|
||||
left,
|
||||
center,
|
||||
shifted,
|
||||
right,
|
||||
leftExpectedId: style._radioTunerStations[0]?.id || null,
|
||||
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
|
||||
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
|
||||
leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null,
|
||||
centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
|
||||
rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null,
|
||||
centerExpected: Math.floor(centerCoordinate + 0.5),
|
||||
tapeDelta,
|
||||
needleDelta,
|
||||
@@ -2817,7 +2923,7 @@ async function main() {
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
// Capture a fresh gesture's complete presentation anchor before moving
|
||||
// to a different absolute directory station.
|
||||
const beforeStations = [...style._radioTunerStations];
|
||||
const beforeStations = [...style._radioControls._radioTunerStations];
|
||||
const sliderRect = slider.getBoundingClientRect();
|
||||
const xFor = (index) => sliderRect.left + 7
|
||||
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
|
||||
@@ -2834,7 +2940,7 @@ async function main() {
|
||||
const flyToCalls = [];
|
||||
camera.flyTo = (options) => flyToCalls.push(options);
|
||||
const before = {
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
ids: beforeStations.map((station) => station.id),
|
||||
slot: Number(slider.value),
|
||||
selectedId: radio.getUIState().selected?.id || null,
|
||||
@@ -2848,10 +2954,10 @@ async function main() {
|
||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||
const flyToCallsBeforeCancel = flyToCalls.length;
|
||||
pointer('pointercancel', targetIndex);
|
||||
const afterStations = style._radioTunerStations;
|
||||
const afterStations = style._radioControls._radioTunerStations;
|
||||
const afterState = radio.getUIState();
|
||||
const after = {
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
ids: afterStations.map((station) => station.id),
|
||||
slot: Number(slider.value),
|
||||
selectedId: afterState.selected?.id || null,
|
||||
@@ -2972,7 +3078,7 @@ async function main() {
|
||||
const tunerRefreshTarget = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const stations = gev.styleManager._radioTunerStations;
|
||||
const stations = gev.styleManager._radioControls._radioTunerStations;
|
||||
const targetIndex = 1;
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
|
||||
@@ -3055,7 +3161,7 @@ async function main() {
|
||||
const max = Number(slider.max);
|
||||
const direction = startSlot < max ? 1 : -1;
|
||||
const targetIndex = startSlot + direction;
|
||||
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
|
||||
const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null;
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
|
||||
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
|
||||
@@ -3101,23 +3207,32 @@ async function main() {
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
JSON.stringify(tunerDirectRelease),
|
||||
);
|
||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
await page.waitForFunction(() => {
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const x = rect.left + 7 + (rect.width - 14) * Number(slider.value) / Math.max(1, Number(slider.max));
|
||||
return document.elementFromPoint(x, rect.top + rect.height / 2) === slider;
|
||||
});
|
||||
const tunerCommitTarget = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const targetIndex = 11;
|
||||
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
|
||||
const station = gev.styleManager._radioTunerStations[targetIndex];
|
||||
const station = gev.styleManager._radioControls._radioTunerStations[targetIndex];
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
|
||||
window.__qaRadioDelayNextPlay = true;
|
||||
return {
|
||||
id: station.id,
|
||||
targetIndex,
|
||||
count: gev.styleManager._radioTunerStations.length,
|
||||
frozenSignature: gev.styleManager._radioTunerBandSignature,
|
||||
count: gev.styleManager._radioControls._radioTunerStations.length,
|
||||
frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature,
|
||||
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
|
||||
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
|
||||
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
|
||||
/ Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1),
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
});
|
||||
@@ -3135,11 +3250,11 @@ async function main() {
|
||||
audioState: state.audioState,
|
||||
tuningStatic: state.tuningStatic,
|
||||
awaiting: state.tuningAwaitingStationId,
|
||||
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
|
||||
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
|
||||
signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature,
|
||||
selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
|
||||
sliderSlot: Number(slider.value),
|
||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
|
||||
pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation,
|
||||
};
|
||||
}, tunerCommitTarget);
|
||||
check(
|
||||
@@ -3183,7 +3298,7 @@ async function main() {
|
||||
y: rect.top + rect.height / 2,
|
||||
ratio,
|
||||
usableWidth: rect.width - 14,
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
};
|
||||
});
|
||||
const microDragSamples = [];
|
||||
@@ -3199,7 +3314,7 @@ async function main() {
|
||||
pixelDelta,
|
||||
slot: Number(slider.value),
|
||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
};
|
||||
}, delta));
|
||||
}
|
||||
@@ -3238,9 +3353,9 @@ async function main() {
|
||||
const gev = window.__godsEyeView;
|
||||
const style = gev.styleManager;
|
||||
const before = {
|
||||
signature: style._radioTunerBandSignature,
|
||||
ids: style._radioTunerStations.map((station) => station.id),
|
||||
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
ids: style._radioControls._radioTunerStations.map((station) => station.id),
|
||||
selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId),
|
||||
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||
};
|
||||
const current = gev.viewer.camera.positionCartographic;
|
||||
@@ -3256,11 +3371,11 @@ async function main() {
|
||||
});
|
||||
gev.viewer.camera.changed.raiseEvent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const stations = gev.styleManager._radioTunerStations;
|
||||
const stations = gev.styleManager._radioControls._radioTunerStations;
|
||||
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
|
||||
return {
|
||||
before,
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
ids: stations.map((station) => station.id),
|
||||
count: stations.length,
|
||||
selectedIndex,
|
||||
@@ -3279,14 +3394,14 @@ async function main() {
|
||||
const nextNeedleBefore = await page.evaluate(() => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
||||
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
|
||||
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
|
||||
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||
const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId);
|
||||
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length;
|
||||
return {
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
selectedIndex,
|
||||
count: style._radioTunerStations.length,
|
||||
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
|
||||
count: style._radioControls._radioTunerStations.length,
|
||||
expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null,
|
||||
};
|
||||
});
|
||||
await page.$eval('#radio-next-btn', (button) => button.click());
|
||||
@@ -3294,15 +3409,15 @@ async function main() {
|
||||
const nextNeedleAfter = await page.evaluate(() => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
||||
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||
return {
|
||||
signature: style._radioTunerBandSignature,
|
||||
signature: style._radioControls._radioTunerBandSignature,
|
||||
selectedId,
|
||||
selectedIndex,
|
||||
count: style._radioTunerStations.length,
|
||||
count: style._radioControls._radioTunerStations.length,
|
||||
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
|
||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||
pinned: style._radioTunerBandPinnedForNavigation,
|
||||
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
|
||||
};
|
||||
});
|
||||
check(
|
||||
@@ -3322,13 +3437,14 @@ async function main() {
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: value,
|
||||
}));
|
||||
const markerId = () => gev.viewer.entities.values
|
||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||
const startIndex = Number(slider.value);
|
||||
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
|
||||
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
|
||||
const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1);
|
||||
const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null;
|
||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||
key('keydown', 'ArrowRight');
|
||||
const preview = {
|
||||
@@ -3353,7 +3469,7 @@ async function main() {
|
||||
tuningActive: radio.getUIState().tuningActive,
|
||||
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
|
||||
};
|
||||
const count = style._radioTunerStations.length;
|
||||
const count = style._radioControls._radioTunerStations.length;
|
||||
const pageStep = Math.max(1, Math.round((count - 1) / 10));
|
||||
const previewAndCancel = (value, expectedSlot) => {
|
||||
const beforePlays = window.__qaRadioPlayCalls.length;
|
||||
@@ -3376,7 +3492,8 @@ async function main() {
|
||||
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
|
||||
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
|
||||
];
|
||||
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation };
|
||||
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation,
|
||||
panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') };
|
||||
});
|
||||
check(
|
||||
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
|
||||
@@ -3388,7 +3505,8 @@ async function main() {
|
||||
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
|
||||
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
|
||||
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
|
||||
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0,
|
||||
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0
|
||||
&& tunerKeyboard.panelStayedOpen,
|
||||
JSON.stringify(tunerKeyboard),
|
||||
);
|
||||
check(
|
||||
@@ -3405,7 +3523,7 @@ async function main() {
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const count = style._radioTunerStations.length;
|
||||
const count = style._radioControls._radioTunerStations.length;
|
||||
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
slider.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
@@ -3425,7 +3543,7 @@ async function main() {
|
||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||
markerId: gev.viewer.entities.values
|
||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
|
||||
expectedId: style._radioTunerStations[centerIndex]?.id || null,
|
||||
expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null,
|
||||
};
|
||||
slider.dispatchEvent(new PointerEvent('pointercancel', {
|
||||
bubbles: true,
|
||||
@@ -3450,7 +3568,7 @@ async function main() {
|
||||
const fullPoolNavigation = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const radio = gev.dataManager.layers.get('radio').module;
|
||||
const pool = gev.styleManager._radioTunerPool;
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const ids = pool.map((station) => station.id);
|
||||
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
|
||||
autoplay: false,
|
||||
@@ -3500,7 +3618,7 @@ async function main() {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const count = gev.styleManager._radioTunerStations.length;
|
||||
const count = gev.styleManager._radioControls._radioTunerStations.length;
|
||||
const current = Number(slider.value);
|
||||
const target = Math.min(count - 1, current + 4);
|
||||
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
|
||||
@@ -3695,7 +3813,7 @@ async function main() {
|
||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||
const pool = gev.styleManager._radioTunerPool;
|
||||
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
|
||||
@@ -4390,7 +4508,10 @@ async function main() {
|
||||
});
|
||||
await page.click('#context-radio-toggle-btn');
|
||||
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||
await sleep(450);
|
||||
await page.waitForFunction((priorScroll) => (
|
||||
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
|
||||
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
|
||||
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
|
||||
const expandedContextRadioAfter = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
/** Exercise installed Scene controls and real project/playback operations. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const shots = path.resolve('qa-shots/scene-controls');
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let promptValue = 'QA Scene';
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined);
|
||||
});
|
||||
let failures = 0;
|
||||
function check(name, ok) {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector?._controls &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__qaSceneState = [];
|
||||
window.__godsEyeView.sceneDirector.subscribe((notification) =>
|
||||
window.__qaSceneState.push(notification),
|
||||
);
|
||||
});
|
||||
await page.click('[data-collapse-target="scene-panel"]');
|
||||
await page.click('#scene-new-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('#scene-select option:checked')?.textContent ===
|
||||
'QA Scene',
|
||||
);
|
||||
check(
|
||||
'New creates and selects a persisted empty scene',
|
||||
await page.evaluate(() => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
return (
|
||||
director._getSelectedScene().shots.length === 0 &&
|
||||
!!document.querySelector('.scene-shot-empty') &&
|
||||
saved.scenes.some((scene) => scene.title === 'QA Scene')
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 1,
|
||||
);
|
||||
check(
|
||||
'Capture stores the actual camera and visual state',
|
||||
await page.evaluate(() => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const shot = director._getSelectedScene().shots[0];
|
||||
const current = director.styleManager.getCameraState();
|
||||
return (
|
||||
Number.isFinite(shot.camera.lat) &&
|
||||
Math.abs(shot.camera.lat - current.lat) < 0.01 &&
|
||||
shot.visual.style === director.styleManager.getVisualState().style
|
||||
);
|
||||
}),
|
||||
);
|
||||
promptValue = '<b>QA shot</b>';
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.scene-shot-label')?.textContent ===
|
||||
'<b>QA shot</b>',
|
||||
);
|
||||
check(
|
||||
'Rename persists literal text without interpreting markup',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('.scene-shot-label b') &&
|
||||
JSON.parse(
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
).scenes.some((scene) =>
|
||||
scene.shots.some((shot) => shot.title === '<b>QA shot</b>'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await page.click('#scene-capture-btn');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.scene-shot-row').length === 2,
|
||||
);
|
||||
await page.click('.scene-shot-label');
|
||||
check(
|
||||
'Shot selection and Update use the selected shot',
|
||||
await page.evaluate(() => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const scene = director._getSelectedScene();
|
||||
const count = scene.shots.length;
|
||||
document.getElementById('scene-update-shot-btn').click();
|
||||
return (
|
||||
director._selectedShotId === scene.shots[0].id &&
|
||||
scene.shots.length === count &&
|
||||
document.querySelector('.scene-shot-row.active .scene-shot-label')
|
||||
.textContent === scene.shots[0].title
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__sceneExports = [];
|
||||
const create = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
if (blob.type === 'application/json')
|
||||
window.__sceneExports.push(blob.text());
|
||||
return create(blob);
|
||||
};
|
||||
});
|
||||
await page.click('#scene-export-btn');
|
||||
const exported = await page.evaluate(async () =>
|
||||
JSON.parse(await window.__sceneExports[0]),
|
||||
);
|
||||
check(
|
||||
'Export produces the current project JSON',
|
||||
exported.scenes.some(
|
||||
(scene) => scene.title === 'QA Scene' && scene.shots.length === 2,
|
||||
),
|
||||
);
|
||||
// Keep real camera/style playback while isolating this control check from
|
||||
// live provider availability. Layer transactions are covered by their suites.
|
||||
const scene = exported.scenes.find((item) => item.title === 'QA Scene');
|
||||
scene.shots = scene.shots
|
||||
.slice(0, 1)
|
||||
.map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} }));
|
||||
const fixture = { ...exported, scenes: [scene] };
|
||||
const projectFile = path.join(shots, 'project.json');
|
||||
fs.writeFileSync(projectFile, JSON.stringify(fixture));
|
||||
const input = await page.$('#scene-import-file');
|
||||
await input.uploadFile(projectFile);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('scene-status').textContent ===
|
||||
'Imported project.json',
|
||||
);
|
||||
check(
|
||||
'Import replaces the project and clears the file input',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes.length === 1 &&
|
||||
document.querySelectorAll('.scene-shot-row').length === 1 &&
|
||||
document.getElementById('scene-import-file').value === '',
|
||||
),
|
||||
);
|
||||
await page.click('.scene-shot-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('scene-status').textContent.startsWith('Loaded:'),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
check(
|
||||
'LOAD completes the real visual and camera operation',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.running &&
|
||||
document
|
||||
.getElementById('scene-status')
|
||||
.textContent.includes('<b>QA shot</b>'),
|
||||
),
|
||||
);
|
||||
await page.click('#scene-start-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.running &&
|
||||
document.body.classList.contains('scene-playback-mode'),
|
||||
);
|
||||
check(
|
||||
'Playback enters recording presentation and disables editing',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.getElementById('scene-capture-btn').disabled &&
|
||||
document.getElementById('scene-start-btn').disabled &&
|
||||
!document.getElementById('scene-stop-btn').disabled &&
|
||||
window.__godsEyeView.styleManager.getControlState().recording,
|
||||
),
|
||||
);
|
||||
// A focused panel owns Escape to collapse one level. Release panel focus
|
||||
// before testing the global playback shortcut; the canvas is not tabbable.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
check(
|
||||
'global playback shortcut has document focus',
|
||||
await page.evaluate(() => document.activeElement === document.body),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.running &&
|
||||
!document.body.classList.contains('scene-playback-mode'),
|
||||
);
|
||||
check(
|
||||
'Escape ends playback and restores idle/recording controls',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.getElementById('scene-start-btn').disabled &&
|
||||
document.getElementById('scene-stop-btn').disabled &&
|
||||
!document.getElementById('scene-download-btn').disabled &&
|
||||
!window.__godsEyeView.styleManager.getControlState().recording,
|
||||
),
|
||||
);
|
||||
// Escape can also collapse the Scene accordion. Reopen it through its
|
||||
// installed disclosure before testing the next visible user action.
|
||||
if (
|
||||
await page.$eval('#scene-panel', (panel) =>
|
||||
panel.classList.contains('collapsed'),
|
||||
)
|
||||
)
|
||||
await page.click('[data-collapse-target="scene-panel"]');
|
||||
await page.waitForSelector('#scene-download-btn', { visible: true });
|
||||
await page.click('#scene-download-btn');
|
||||
const metadata = await page.evaluate(async () =>
|
||||
JSON.parse(await window.__sceneExports.at(-1)),
|
||||
);
|
||||
check(
|
||||
'Run download contains the completed cancelled run',
|
||||
Boolean(
|
||||
metadata.endedAt &&
|
||||
metadata.wasCancelled &&
|
||||
Array.isArray(metadata.events),
|
||||
),
|
||||
);
|
||||
await page.click('#scene-start-btn');
|
||||
await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running);
|
||||
await page.$eval('#scene-stop-btn', (button) => button.click());
|
||||
await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running);
|
||||
check(
|
||||
'The installed Stop control also completes playback cleanup',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.body.classList.contains('scene-playback-mode') &&
|
||||
!document.getElementById('scene-start-btn').disabled,
|
||||
),
|
||||
);
|
||||
const badFile = path.join(shots, 'invalid.json');
|
||||
fs.writeFileSync(badFile, '{invalid');
|
||||
await input.uploadFile(badFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.getElementById('scene-status')
|
||||
.textContent.includes('invalid JSON'),
|
||||
);
|
||||
check(
|
||||
'Invalid import reports failure and preserves the current project',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes.length === 1 &&
|
||||
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
|
||||
'QA Scene',
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
check(
|
||||
'Scene controls stay inside the narrow viewport',
|
||||
await page.$eval('#scene-select', (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth;
|
||||
}),
|
||||
);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.click('.scene-shot-danger');
|
||||
await page.waitForFunction(
|
||||
() => !!document.querySelector('.scene-shot-empty'),
|
||||
);
|
||||
check(
|
||||
'Delete shot leaves the empty-state presentation',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._getSelectedScene().shots.length ===
|
||||
0,
|
||||
),
|
||||
);
|
||||
await page.click('#scene-delete-btn');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector._project.scenes.some(
|
||||
(item) => item.title === 'QA Scene',
|
||||
),
|
||||
);
|
||||
check(
|
||||
'Deleting the final scene restores the built-in recipes',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes.length > 0 &&
|
||||
document.querySelectorAll('#scene-select option').length > 0,
|
||||
),
|
||||
);
|
||||
check(
|
||||
'Scene subscriptions include current state and the completed native editing actions',
|
||||
await page.evaluate(() => {
|
||||
const seen = window.__qaSceneState;
|
||||
const types = new Set(seen.map(({ change }) => change?.type));
|
||||
return (
|
||||
seen[0].initial &&
|
||||
Object.isFrozen(seen[0].state) &&
|
||||
[
|
||||
'scene-created',
|
||||
'shot-captured',
|
||||
'shot-renamed',
|
||||
'shot-updated',
|
||||
'project-exported',
|
||||
'project-imported',
|
||||
'shot-deleted',
|
||||
'scene-deleted',
|
||||
'run-event',
|
||||
].every((type) => types.has(type)) &&
|
||||
seen
|
||||
.filter(({ change }) => change?.shot)
|
||||
.every(({ change }) => Object.isFrozen(change.shot))
|
||||
);
|
||||
}),
|
||||
);
|
||||
const teardown = await page.evaluate(async () => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const controls = director._controls;
|
||||
const previousStatus = document.getElementById('scene-status').textContent;
|
||||
const project = director._project;
|
||||
let finish;
|
||||
const gate = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const pending = controls.run('import', {
|
||||
name: 'late.json',
|
||||
text: async () => {
|
||||
await gate;
|
||||
return '{"scenes":[]}';
|
||||
},
|
||||
});
|
||||
const stopping = director.destroy();
|
||||
const notificationsAtStop = window.__qaSceneState.length;
|
||||
const stoppedSynchronously =
|
||||
controls.destroyed &&
|
||||
controls.removers.length === 0 &&
|
||||
controls.rowRemovers.length === 0;
|
||||
document.getElementById('scene-new-btn').click();
|
||||
controls.run('capture');
|
||||
finish();
|
||||
await pending;
|
||||
await stopping;
|
||||
await director.destroy();
|
||||
return {
|
||||
stoppedSynchronously,
|
||||
stateStopped: window.__qaSceneState.length === notificationsAtStop,
|
||||
sameProject: director._project === project,
|
||||
noLateStatus:
|
||||
document.getElementById('scene-status').textContent === previousStatus,
|
||||
rowsGone: !document.querySelector('.scene-shot-row'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Disposal revokes fixed and row actions before waiting for scene work',
|
||||
teardown.stoppedSynchronously && teardown.rowsGone,
|
||||
);
|
||||
check(
|
||||
'Late import completion cannot replace the disposed project or status',
|
||||
teardown.sameProject && teardown.noLateStatus,
|
||||
);
|
||||
check(
|
||||
'Scene subscriptions stop before late imports settle',
|
||||
teardown.stateStopped,
|
||||
);
|
||||
check(
|
||||
'Scene control interaction produces no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic city-navigation exercise; road/flow responses are fixtures.
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const url = args[args.indexOf('--url') + 1] || 'http://localhost:4173';
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 120000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
try {
|
||||
for (const cctv of [false, true]) {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
let londonRequests = 0;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.text().includes('[Data:Traffic]'))
|
||||
console.log(message.text());
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
let response = trafficFixtureResponse(request);
|
||||
const query = new URLSearchParams(request.postData()).get('data') || '';
|
||||
if (query.includes('highway') && query.includes('(51.')) {
|
||||
londonRequests++;
|
||||
if (londonRequests === 1)
|
||||
response = { status: 504, body: 'Temporary road source timeout' };
|
||||
}
|
||||
return response ? request.respond(response) : request.continue();
|
||||
});
|
||||
await page.goto(`${url}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: 60000,
|
||||
});
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(async (withCctv) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const C = await import('/node_modules/.vite/deps/cesium.js');
|
||||
for (const id of ['satellites', 'flights', 'military'])
|
||||
await dataManager.setEnabled(id, false);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(-97.744, 30.267, 3200),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('traffic', true);
|
||||
if (withCctv) await dataManager.setEnabled('cctv', true);
|
||||
}, cctv);
|
||||
const ready = () => {
|
||||
const stats = window.__godsEyeView.dataManager.layers
|
||||
.get('traffic')
|
||||
.module.getStats();
|
||||
return stats.count > 0 && !stats.loading;
|
||||
};
|
||||
try {
|
||||
await page.waitForFunction(ready, { timeout: 45000 });
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Traffic timeout state',
|
||||
await page.evaluate(() => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
return {
|
||||
stats: dataManager.layers.get('traffic').module.getStats(),
|
||||
camera: viewer.camera.positionCartographic,
|
||||
};
|
||||
}),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await sleep(2500);
|
||||
await page.evaluate(async () => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const C = await import('/node_modules/.vite/deps/cesium.js');
|
||||
viewer.camera.flyTo({
|
||||
destination: C.Cartesian3.fromDegrees(-0.1276, 51.5072, 3200),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
duration: 4,
|
||||
});
|
||||
});
|
||||
const deadline = Date.now() + 45000;
|
||||
while (londonRequests < 2 && Date.now() < deadline) await sleep(500);
|
||||
assert.ok(
|
||||
londonRequests >= 2,
|
||||
'London must recover its failed request without a toggle',
|
||||
);
|
||||
await page.waitForFunction(ready, { timeout: 45000 });
|
||||
const stats = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(),
|
||||
);
|
||||
assert.equal(stats.error, null);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: Austin → London, first destination request fails then recovers',
|
||||
{ cctv, londonRequests, count: stats.count },
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('traffic', false),
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('traffic')
|
||||
.module.getStats().loading,
|
||||
),
|
||||
false,
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
+21
-6
@@ -25,6 +25,8 @@
|
||||
* Visual proof saved to qa-shots/ (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
|
||||
* Add --fixtures for synthetic roads and the recorded flow tile without a key;
|
||||
* that mode does not qualify live source access or quota accounting.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
@@ -44,6 +46,8 @@ const getOpt = (name, dflt) => {
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4410');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null;
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -112,7 +116,8 @@ async function main() {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
||||
if (!statusBefore?.hasKey) {
|
||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||
process.exit(2);
|
||||
@@ -133,6 +138,11 @@ async function main() {
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const onFixture = (request) => {
|
||||
const response = fixtureResponse(request);
|
||||
if (response) void request.respond(response); else void request.continue();
|
||||
};
|
||||
if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); }
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
|
||||
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
|
||||
@@ -152,7 +162,8 @@ async function main() {
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
|
||||
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
|
||||
@@ -240,16 +251,17 @@ async function main() {
|
||||
// ── (iii) budget honesty ─────────────────────────────────────────────────
|
||||
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
|
||||
{
|
||||
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
|
||||
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
|
||||
const ok = grew >= 0 && grew <= flowRequests.length;
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
|
||||
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
|
||||
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok,
|
||||
FIXTURES ? 'Synthetic source mode; live quota was not exercised' : `before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
|
||||
if (!ok) exitCode = 1;
|
||||
}
|
||||
|
||||
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
|
||||
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
|
||||
if (FIXTURES) page.off('request', onFixture);
|
||||
await page.setRequestInterception(true);
|
||||
const keylessFlowReqs = [];
|
||||
page.on('request', (req) => {
|
||||
@@ -263,6 +275,8 @@ async function main() {
|
||||
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
||||
return;
|
||||
}
|
||||
const response = fixtureResponse(req);
|
||||
if (response) { void req.respond(response); return; }
|
||||
try { req.continue(); } catch { /* already handled */ }
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -270,7 +284,8 @@ async function main() {
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
|
||||
if (!simStats || simStats.count === 0) {
|
||||
// Public Overpass can throttle bursts across harness runs — one retry.
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
/** Exercise the real UI disposal path, including unrelated resource owners. */
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
function check(name, ok) {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
try {
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._dataManager &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async () => {
|
||||
const ui = window.__godsEyeView.styleManager;
|
||||
const counts = {};
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: '`',
|
||||
code: 'Backquote',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
const fpsReadout = document.querySelector('.frame-rate-readout');
|
||||
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
|
||||
const watchObserver = (name) => {
|
||||
const owner =
|
||||
name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout;
|
||||
const observer = owner[name];
|
||||
if (!observer) return false;
|
||||
const disconnect = observer.disconnect.bind(observer);
|
||||
counts[name] = 0;
|
||||
observer.disconnect = () => {
|
||||
counts[name]++;
|
||||
return disconnect();
|
||||
};
|
||||
return true;
|
||||
};
|
||||
const observed = [
|
||||
'_commandDockTrayObserver',
|
||||
'_draggableResizeObserver',
|
||||
'_leftStackResizeObserver',
|
||||
'_rightStackResizeObserver',
|
||||
'_leftStackMutationObserver',
|
||||
'_rightStackMutationObserver',
|
||||
].filter(watchObserver);
|
||||
const cctvUnsubscribe = ui._cctvControls._cctvUnsubscribe;
|
||||
counts.cctv = 0;
|
||||
if (cctvUnsubscribe)
|
||||
ui._cctvControls._cctvUnsubscribe = () => {
|
||||
counts.cctv++;
|
||||
return cctvUnsubscribe();
|
||||
};
|
||||
const contextFields = [
|
||||
'_contextManagerUnsubscribe',
|
||||
'_dataManagerVisibilityRequestUnsubscribe',
|
||||
'_dataManagerVisibilityGuardUnsubscribe',
|
||||
'_dataManagerBeforeDestroyUnsubscribe',
|
||||
];
|
||||
const contextConnected = contextFields.every(
|
||||
(field) => typeof ui._contextControls[field] === 'function',
|
||||
);
|
||||
for (const field of contextFields) {
|
||||
const unsubscribe = ui._contextControls[field];
|
||||
counts[field] = 0;
|
||||
ui._contextControls[field] = () => {
|
||||
counts[field]++;
|
||||
return unsubscribe?.();
|
||||
};
|
||||
}
|
||||
const cockpit = ui.cockpitView;
|
||||
const portal = ui._cockpitDisplayPortal;
|
||||
const cockpitListenerCount = cockpit._listenerRemovers.length;
|
||||
counts.cockpit = 0;
|
||||
cockpit._listenerRemovers = cockpit._listenerRemovers.map(
|
||||
(remove) => () => {
|
||||
counts.cockpit++;
|
||||
return remove();
|
||||
},
|
||||
);
|
||||
const portalHomes = portal.records.map((record) => ({
|
||||
group: record.group,
|
||||
home: record.anchor.parentNode,
|
||||
anchor: record.anchor,
|
||||
}));
|
||||
portal.setActive(true);
|
||||
let releaseRestoration;
|
||||
const restorationGate = new Promise((resolve) => {
|
||||
releaseRestoration = resolve;
|
||||
});
|
||||
const restoreForDisposal = ui._contextControls.restoreForDisposal.bind(
|
||||
ui._contextControls,
|
||||
);
|
||||
ui._contextControls.restoreForDisposal = async () => {
|
||||
await restorationGate;
|
||||
return restoreForDisposal();
|
||||
};
|
||||
const resizeHandler = ui._windowResizeHandler;
|
||||
const removeEventListener = window.removeEventListener;
|
||||
counts.resize = 0;
|
||||
window.removeEventListener = function (type, callback, options) {
|
||||
if (type === 'resize' && callback === resizeHandler) counts.resize++;
|
||||
return removeEventListener.call(this, type, callback, options);
|
||||
};
|
||||
const { setSplitFlapText } = await import('/src/splitFlap.js');
|
||||
const feedbackLabel = document.getElementById('global-loading-label');
|
||||
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
|
||||
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
|
||||
const permanentText =
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
|
||||
let stateNotifications = 0;
|
||||
ui.subscribeShareState(() => stateNotifications++, { emitCurrent: false });
|
||||
ui.subscribeLocationSearch(() => stateNotifications++, {
|
||||
emitCurrent: false,
|
||||
});
|
||||
try {
|
||||
const disposal = ui.dispose();
|
||||
const focusBefore = document.activeElement;
|
||||
const stoppedBeforeRestoration =
|
||||
ui._panelLayout.destroyed &&
|
||||
ui._panelPosition.destroyed &&
|
||||
ui._feedback.destroyed &&
|
||||
ui._recording.destroyed &&
|
||||
cockpit.destroyed &&
|
||||
portal.stopped &&
|
||||
cockpit._listenerRemovers.length === 0 &&
|
||||
portal.frames.size === 0 &&
|
||||
cockpit.enter() === false &&
|
||||
cockpit.navigateContext(1) === false;
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
const noDeferredFocus = document.activeElement === focusBefore;
|
||||
releaseRestoration();
|
||||
await disposal;
|
||||
ui._syncShareState();
|
||||
ui.subscribeShareState(() => stateNotifications++);
|
||||
ui.subscribeLocationSearch(() => stateNotifications++);
|
||||
const once = JSON.stringify(counts);
|
||||
await ui.dispose();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
return {
|
||||
cockpitStoppedBeforeRestoration:
|
||||
stoppedBeforeRestoration && noDeferredFocus,
|
||||
cockpitReleased:
|
||||
cockpit.disposed &&
|
||||
cockpit.destroyed &&
|
||||
counts.cockpit === cockpitListenerCount &&
|
||||
cockpitListenerCount > 0 &&
|
||||
portal.destroyed &&
|
||||
portal.listeners.signal.aborted &&
|
||||
portal.frames.size === 0 &&
|
||||
ui._cockpitDisplayPortal === null &&
|
||||
portalHomes.length === 4 &&
|
||||
portalHomes.every(
|
||||
(record) =>
|
||||
record.group.parentNode === record.home &&
|
||||
!record.anchor.isConnected,
|
||||
),
|
||||
connected: Boolean(
|
||||
cctvUnsubscribe &&
|
||||
resizeHandler &&
|
||||
observed.includes('_commandDockTrayObserver'),
|
||||
),
|
||||
contextReleased:
|
||||
contextConnected &&
|
||||
ui._contextControls.destroyed &&
|
||||
contextFields.every(
|
||||
(field) =>
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) =>
|
||||
counts[name] === 1 &&
|
||||
(name === '_draggableResizeObserver'
|
||||
? ui._panelPosition
|
||||
: ui._panelLayout)[name] === null,
|
||||
),
|
||||
subscriptionReleased:
|
||||
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
|
||||
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
|
||||
controlsReleased:
|
||||
ui._radioControls.destroyed &&
|
||||
ui._locationControls.destroyed &&
|
||||
ui._cctvControls.destroyed,
|
||||
feedbackTextPreserved:
|
||||
permanentText?.nodeType === Node.TEXT_NODE &&
|
||||
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
|
||||
permanentText &&
|
||||
permanentText.data === 'LOAD COMPLETE' &&
|
||||
!feedbackLabel.classList.contains('gev-flap-active') &&
|
||||
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
|
||||
0,
|
||||
shellWorkReleased:
|
||||
ui._lifetime.destroyed &&
|
||||
ui._lifetime.frames.size === 0 &&
|
||||
ui._lifetime.timers.size === 0 &&
|
||||
ui._lifetime.removers.size === 0,
|
||||
frameRateReleased:
|
||||
fpsWasVisible && !fpsReadout.isConnected && fpsReadout.hidden,
|
||||
stateStopped: stateNotifications === 0,
|
||||
idempotent: once === JSON.stringify(counts),
|
||||
};
|
||||
} finally {
|
||||
releaseRestoration();
|
||||
window.removeEventListener = removeEventListener;
|
||||
}
|
||||
});
|
||||
check(
|
||||
'visible frame-rate monitor is removed on UI disposal',
|
||||
result.frameRateReleased,
|
||||
);
|
||||
check(
|
||||
'real UI has the expected live resources before disposal',
|
||||
result.connected,
|
||||
);
|
||||
check(
|
||||
'UI disposal releases all Context subscriptions and stops its controls',
|
||||
result.contextReleased,
|
||||
);
|
||||
check(
|
||||
'UI disposal disconnects each active panel observer once',
|
||||
result.observersReleased,
|
||||
);
|
||||
check(
|
||||
'UI disposal releases the CCTV state subscription',
|
||||
result.subscriptionReleased,
|
||||
);
|
||||
check(
|
||||
'UI disposal removes the registered window resize handler',
|
||||
result.resizeReleased,
|
||||
);
|
||||
check(
|
||||
'Radio, Location and CCTV controls are disposed with the UI',
|
||||
result.controlsReleased,
|
||||
);
|
||||
check(
|
||||
'Cockpit actions and queued portal work stop before layer restoration',
|
||||
result.cockpitStoppedBeforeRestoration,
|
||||
);
|
||||
check(
|
||||
'Cockpit cleanup releases listeners and returns all Display groups home',
|
||||
result.cockpitReleased,
|
||||
);
|
||||
check(
|
||||
'feedback teardown preserves the permanent accessible text node',
|
||||
result.feedbackTextPreserved,
|
||||
);
|
||||
check(
|
||||
'shell teardown cancels deferred work and releases listeners',
|
||||
result.shellWorkReleased,
|
||||
);
|
||||
check(
|
||||
'state subscriptions stop synchronously with UI disposal',
|
||||
result.stateStopped,
|
||||
);
|
||||
check('repeated UI disposal is inert', result.idempotent);
|
||||
check(
|
||||
'disposal and subsequent resize produce no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -13,14 +13,14 @@
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-vessel-cards.mjs --tag new # current code
|
||||
* node scripts/qa-vessel-cards.mjs --tag old # after swapping in the old layer file
|
||||
* node scripts/qa-vessel-cards.mjs --tag old # from a checkout of the comparison revision
|
||||
* node scripts/qa-vessel-cards.mjs --ports rotterdam # subset
|
||||
* node scripts/qa-vessel-cards.mjs --url http://localhost:4173
|
||||
* node scripts/qa-vessel-cards.mjs --data synthetic # deterministic test fixture
|
||||
* node scripts/qa-vessel-cards.mjs --data synthetic --headful # real-GPU capture
|
||||
*
|
||||
* A/B flow: run --tag new, `git show <old-rev>:src/data/aisLiveVessels.js >
|
||||
* src/data/aisLiveVessels.js`, run --tag old, then `git restore` the file.
|
||||
* A/B flow: run each accepted revision from a separate checkout, using the
|
||||
* same source configuration and camera fixtures with distinct --tag values.
|
||||
* Retained screenshots and same-basename JSON manifests land at
|
||||
* qa-shots/vessel-cards-<live-aisstream|synthetic-fixture>-<tag>-<port>.*.
|
||||
* The name and manifest both record the data source; the manifest also records
|
||||
@@ -253,6 +253,10 @@ async function main() {
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1600, height: 900 });
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// This harness inspects cards, so keep first-run chrome out of the view.
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -319,7 +323,10 @@ async function main() {
|
||||
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
|
||||
});
|
||||
}, port);
|
||||
const resettled = settled ? true : await page
|
||||
// A changed camera invalidates the previous settled result. Let the new
|
||||
// view schedule its tiles before checking readiness again.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const resettled = await page
|
||||
.waitForFunction(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for style transitions and effect-state ownership. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
function check(name, passed) {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173');
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.styleManager?._visualEffects &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (-97.7431 * Math.PI) / 180,
|
||||
latitude: (30.2568 * Math.PI) / 180,
|
||||
height: 1200,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -0.85, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible tile content settles before visual captures', settled);
|
||||
fs.mkdirSync('qa-shots/visual-effects', { recursive: true });
|
||||
for (const style of [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
'normal',
|
||||
]) {
|
||||
await page.evaluate(
|
||||
(name) => window.__godsEyeView.styleManager.setStyle(name),
|
||||
style,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const state = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const effects = manager._visualEffects;
|
||||
const visible = Object.entries(effects.stages)
|
||||
.filter(([, stage]) => stage.enabled)
|
||||
.map(([name]) => name);
|
||||
const animated = Object.values(effects.stages).some(
|
||||
(stage) =>
|
||||
stage.enabled &&
|
||||
stage.uniforms.time !== undefined &&
|
||||
stage.uniforms.intensity > 0.001,
|
||||
);
|
||||
return {
|
||||
active: manager.activeStyle,
|
||||
visible,
|
||||
clock: effects.frameId !== null,
|
||||
animated,
|
||||
};
|
||||
});
|
||||
check(
|
||||
`${style}: only the selected style remains visible`,
|
||||
state.active === style &&
|
||||
JSON.stringify(state.visible) ===
|
||||
JSON.stringify(style === 'normal' ? [] : [style]),
|
||||
);
|
||||
check(
|
||||
`${style}: the animation clock follows visible animated stages`,
|
||||
state.clock === state.animated,
|
||||
);
|
||||
if (['normal', 'surveillance', 'thermal'].includes(style)) {
|
||||
await page.screenshot({ path: `qa-shots/visual-effects/${style}.png` });
|
||||
if (style === 'surveillance')
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.viewer.camera.lookRight(0.2),
|
||||
);
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.setStyle('noir');
|
||||
manager.setStyle('retro');
|
||||
manager.setStyle('normal');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'rapid switches settle to Normal without an old style or clock',
|
||||
await page.evaluate(() => {
|
||||
const effects = window.__godsEyeView.styleManager._visualEffects;
|
||||
return (
|
||||
effects.frameId === null &&
|
||||
Object.values(effects.stages).every((stage) => !stage.enabled)
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'bloom actions, effect state and saved snapshot agree',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const states = [];
|
||||
const unsubscribe = manager.subscribeShareState((value) =>
|
||||
states.push(value),
|
||||
);
|
||||
const result = manager.setBloom({ enabled: true, intensityPct: 98 });
|
||||
unsubscribe();
|
||||
const published = states.at(-1);
|
||||
const snapshot = manager.getVisualState();
|
||||
return (
|
||||
states[0].initial &&
|
||||
states.length > 1 &&
|
||||
Object.isFrozen(published.state.options) &&
|
||||
published.state.bloomEnabled &&
|
||||
published.state.options.bloomIntensity === 98 &&
|
||||
result.ok &&
|
||||
result.bloom.intensityPct === 98 &&
|
||||
snapshot.bloom.intensity === 98 &&
|
||||
manager._visualEffects.bloomIntensity === 98 &&
|
||||
manager._bloomStage.enabled
|
||||
);
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'sharpen actions, effect state and saved snapshot agree',
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const result = manager.setSharpen({ enabled: true, intensityPct: 61 });
|
||||
return (
|
||||
result.ok &&
|
||||
result.sharpen.intensityPct === 61 &&
|
||||
manager.getVisualState().sharpen.intensity === 61 &&
|
||||
manager._visualEffects.sharpenIntensity === 0.61 &&
|
||||
Math.abs(manager._sharpenStage.uniforms.amount - 1.32) < 1e-9
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.setViewport({ width: 620, height: 900 });
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: 'qa-shots/visual-effects/narrow.png' });
|
||||
const teardown = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const effects = manager._visualEffects;
|
||||
manager.setStyle('retro');
|
||||
const owned = [...Object.values(effects.stages), effects.sharpenStage];
|
||||
const collection = manager.viewer.scene.postProcessStages;
|
||||
const previousBloom = effects.previousBloom;
|
||||
const pending = manager.dispose();
|
||||
const stoppedBeforeAwait =
|
||||
effects.stopped &&
|
||||
effects.frameId === null &&
|
||||
effects.transitions.size === 0;
|
||||
await pending;
|
||||
return {
|
||||
stoppedBeforeAwait,
|
||||
removed: owned.every((stage) => !collection.contains(stage)),
|
||||
restored:
|
||||
effects.bloomStage.enabled === previousBloom.enabled &&
|
||||
Object.entries(previousBloom.uniforms).every(
|
||||
([name, value]) => effects.bloomStage.uniforms[name] === value,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'UI teardown stops effect animation before its first await',
|
||||
teardown.stoppedBeforeAwait,
|
||||
);
|
||||
check(
|
||||
'UI teardown removes owned stages and restores borrowed bloom',
|
||||
teardown.removed && teardown.restored,
|
||||
);
|
||||
check(
|
||||
'effects do not introduce uncaught browser errors',
|
||||
errors.length === 0,
|
||||
);
|
||||
if (errors.length) console.log(JSON.stringify(errors));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -3203,10 +3203,25 @@ async function main() {
|
||||
await fl.update(v);
|
||||
await window.__dfSettle(600);
|
||||
}
|
||||
const bb1 = window.__dfFindBB('aaa097');
|
||||
const d1 = bb1 ? window.__dfCarto(bb1.position) : null;
|
||||
// The floor owner intentionally retains the previous cell near an
|
||||
// edge. Sample inside a cell, beyond that hysteresis band, so the raw
|
||||
// coordinate's floor is unambiguously the floor the sprite must use.
|
||||
// Wait for geometry, not a passing height; a missing clamp still fails.
|
||||
const interiorLimit = 0.0005 - gf.CELL_HYSTERESIS_DEG - 0.00002;
|
||||
const sampleDeadline = Date.now() + 8000;
|
||||
let d1 = null;
|
||||
let sampleInterior = false;
|
||||
do {
|
||||
await window.__dfSettle(250);
|
||||
const bb1 = window.__dfFindBB('aaa097');
|
||||
d1 = bb1 ? window.__dfCarto(bb1.position) : null;
|
||||
sampleInterior = !!d1
|
||||
&& Math.abs(d1.lat - cell(d1.lat)) < interiorLimit
|
||||
&& Math.abs(d1.lon - cell(d1.lon)) < interiorLimit;
|
||||
} while (!sampleInterior && Date.now() < sampleDeadline);
|
||||
return {
|
||||
startCold,
|
||||
sampleInterior,
|
||||
displayCell,
|
||||
fixCell,
|
||||
sameCellAsFix: displayCell.lat === fixCell.lat && displayCell.lon === fixCell.lon,
|
||||
@@ -3249,6 +3264,10 @@ async function main() {
|
||||
dfCorridor.controlFloor == null,
|
||||
`control cell floor = ${dfCorridor.controlFloor}`);
|
||||
|
||||
record('display-floor/corridor: the height sample clears cell-boundary hysteresis',
|
||||
dfCorridor.sampleInterior === true,
|
||||
'the sampled coordinate lies inside one unambiguous floor cell');
|
||||
|
||||
record('display-floor/corridor: the sprite rides the corridor-warmed floor',
|
||||
Number.isFinite(dfCorridor.spriteH) && Number.isFinite(dfCorridor.spriteFloor)
|
||||
&& dfCorridor.spriteH >= dfCorridor.spriteFloor + DISPLAY_FLOOR_LIFT_M - 0.5,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js';
|
||||
import { tilesForBounds } from '../src/data/tomtomTiles.js';
|
||||
|
||||
const tile = readFileSync(
|
||||
new URL(
|
||||
'../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const json = (body) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */
|
||||
export function trafficFixtureResponse(request) {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/api/tomtom/status')
|
||||
return json({ hasKey: true, dailyCount: 0 });
|
||||
if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname))
|
||||
return { status: 200, contentType: 'application/x-protobuf', body: tile };
|
||||
if (url.pathname !== '/api/overpass') return null;
|
||||
const query = new URLSearchParams(request.postData()).get('data') || '';
|
||||
if (!query.includes('highway')) return null;
|
||||
const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/);
|
||||
if (!match) throw new Error('Missing road fixture viewport');
|
||||
const [south, west, north, east] = match.slice(1).map(Number);
|
||||
const inside = ([lon, lat]) =>
|
||||
lat >= south && lat <= north && lon >= west && lon <= east;
|
||||
const segments = tilesForBounds({ south, west, north, east }, 12).flatMap(
|
||||
({ z, x, y }) => decodeFlowTile(tile, z, x, y),
|
||||
);
|
||||
const elements = segments
|
||||
.filter((segment) => segment.coords.some(inside))
|
||||
.slice(0, 240)
|
||||
.map((segment, index) => ({
|
||||
type: 'way',
|
||||
id: index + 1,
|
||||
tags: { highway: 'primary', oneway: 'yes' },
|
||||
geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })),
|
||||
}));
|
||||
const latitude = (south + north) / 2,
|
||||
longitude = (west + east) / 2;
|
||||
elements.push({
|
||||
type: 'way',
|
||||
id: 99999,
|
||||
tags: { highway: 'residential' },
|
||||
geometry: [
|
||||
{ lat: latitude, lon: longitude },
|
||||
{ lat: latitude + 0.003, lon: longitude + 0.003 },
|
||||
],
|
||||
});
|
||||
return json({ elements });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/** Copy plain product data so a subscriber cannot mutate its owner's state. */
|
||||
function copyState(value, ancestors = new Set()) {
|
||||
if (value === null) return value;
|
||||
if (typeof value !== 'object') {
|
||||
if (['string', 'number', 'boolean', 'undefined'].includes(typeof value))
|
||||
return value;
|
||||
throw new TypeError('Product state must contain plain data');
|
||||
}
|
||||
if (ancestors.has(value)) throw new TypeError('Circular product state');
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null
|
||||
) {
|
||||
throw new TypeError('Product state must contain plain records');
|
||||
}
|
||||
const next = new Set(ancestors).add(value);
|
||||
const copy = Array.isArray(value)
|
||||
? value.map((item) => copyState(item, next))
|
||||
: Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
copyState(item, next),
|
||||
]),
|
||||
);
|
||||
return Object.freeze(copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish immutable snapshots and ordinary action outcomes synchronously.
|
||||
* Reentrant publications retain order. New subscribers start at current state;
|
||||
* removed subscribers and destroyed channels cannot receive queued work.
|
||||
*/
|
||||
export function createStateChannel(readSnapshot) {
|
||||
const listeners = new Set();
|
||||
const pending = [];
|
||||
let revision = 0;
|
||||
let delivering = false;
|
||||
let destroyed = false;
|
||||
let lastSnapshot = null;
|
||||
const getSnapshot = () => {
|
||||
if (!destroyed) lastSnapshot = copyState(readSnapshot());
|
||||
return lastSnapshot;
|
||||
};
|
||||
const deliver = (entry, notification) => {
|
||||
try {
|
||||
entry.listener(notification);
|
||||
} catch {
|
||||
console.error('Product state listener failed');
|
||||
}
|
||||
};
|
||||
return Object.freeze({
|
||||
getSnapshot,
|
||||
subscribe(listener, { emitCurrent = true } = {}) {
|
||||
if (typeof listener !== 'function')
|
||||
throw new TypeError('Expected a state listener');
|
||||
if (destroyed) return () => {};
|
||||
const state = emitCurrent ? getSnapshot() : null;
|
||||
const entry = { listener, revision };
|
||||
listeners.add(entry);
|
||||
if (emitCurrent)
|
||||
deliver(
|
||||
entry,
|
||||
Object.freeze({
|
||||
state,
|
||||
change: null,
|
||||
revision,
|
||||
initial: true,
|
||||
}),
|
||||
);
|
||||
return () => listeners.delete(entry);
|
||||
},
|
||||
publish(change = null) {
|
||||
if (destroyed) return false;
|
||||
pending.push(
|
||||
Object.freeze({
|
||||
state: getSnapshot(),
|
||||
change: copyState(change),
|
||||
revision: ++revision,
|
||||
initial: false,
|
||||
}),
|
||||
);
|
||||
if (delivering) return true;
|
||||
delivering = true;
|
||||
try {
|
||||
while (!destroyed && pending.length) {
|
||||
const notification = pending.shift();
|
||||
for (const entry of [...listeners]) {
|
||||
if (
|
||||
destroyed ||
|
||||
!listeners.has(entry) ||
|
||||
entry.revision >= notification.revision
|
||||
)
|
||||
continue;
|
||||
entry.revision = notification.revision;
|
||||
deliver(entry, notification);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delivering = false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
listeners.clear();
|
||||
pending.length = 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStateChannel } from './stateChannel.js';
|
||||
|
||||
test('snapshots and action data are immutable copies of live product state', () => {
|
||||
const live = { selected: { id: 'a' }, items: ['a'] };
|
||||
const channel = createStateChannel(() => live);
|
||||
const received = [];
|
||||
channel.subscribe((event) => received.push(event));
|
||||
const change = { type: 'selected', item: { id: 'b' } };
|
||||
live.selected.id = 'b';
|
||||
channel.publish(change);
|
||||
change.item.id = 'mutated';
|
||||
assert.equal(received[0].initial, true);
|
||||
assert.equal(received[0].state.selected.id, 'a');
|
||||
assert.equal(received[1].state.selected.id, 'b');
|
||||
assert.equal(received[1].change.item.id, 'b');
|
||||
assert.throws(() => received[1].state.items.push('c'), TypeError);
|
||||
assert.deepEqual(live.items, ['a']);
|
||||
});
|
||||
|
||||
test('reentrant updates retain order and a new subscriber never regresses behind its initial snapshot', () => {
|
||||
let count = 0;
|
||||
const channel = createStateChannel(() => ({ count }));
|
||||
const first = [],
|
||||
second = [],
|
||||
joined = [];
|
||||
channel.subscribe(({ state, initial }) => {
|
||||
if (initial) return;
|
||||
first.push(state.count);
|
||||
if (state.count === 1) {
|
||||
count = 2;
|
||||
channel.publish({ type: 'next' });
|
||||
channel.subscribe(({ state }) => joined.push(state.count));
|
||||
}
|
||||
});
|
||||
channel.subscribe(({ state, initial }) => {
|
||||
if (!initial) second.push(state.count);
|
||||
});
|
||||
count = 1;
|
||||
channel.publish();
|
||||
assert.deepEqual(first, [1, 2]);
|
||||
assert.deepEqual(second, [1, 2]);
|
||||
assert.deepEqual(joined, [2]);
|
||||
});
|
||||
|
||||
test('unsubscribe and destroy during delivery revoke pending callbacks and future reads', () => {
|
||||
let reads = 0;
|
||||
const channel = createStateChannel(() => ({ reads: ++reads }));
|
||||
let late = 0;
|
||||
let remove;
|
||||
channel.subscribe(
|
||||
() => {
|
||||
remove();
|
||||
channel.publish();
|
||||
channel.destroy();
|
||||
},
|
||||
{ emitCurrent: false },
|
||||
);
|
||||
remove = channel.subscribe(() => late++, { emitCurrent: false });
|
||||
channel.publish();
|
||||
const stoppedReads = reads;
|
||||
channel.publish();
|
||||
channel.subscribe(() => late++);
|
||||
channel.destroy();
|
||||
remove();
|
||||
assert.equal(late, 0);
|
||||
assert.equal(reads, stoppedReads);
|
||||
});
|
||||
|
||||
test('a failed subscriber cannot prevent other controls from receiving an update', () => {
|
||||
const channel = createStateChannel(() => ({ selected: 'a' }));
|
||||
const errors = [];
|
||||
const original = console.error;
|
||||
console.error = (message) => errors.push(message);
|
||||
try {
|
||||
channel.subscribe(() => {
|
||||
throw new Error('consumer failure');
|
||||
});
|
||||
let received = 0;
|
||||
channel.subscribe(() => received++);
|
||||
channel.publish();
|
||||
assert.equal(received, 2);
|
||||
assert.equal(errors.length, 2);
|
||||
} finally {
|
||||
console.error = original;
|
||||
channel.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid initial snapshot cannot retain a subscriber without a cleanup handle', () => {
|
||||
let live = { action() {} };
|
||||
const channel = createStateChannel(() => live);
|
||||
let calls = 0;
|
||||
assert.throws(() => channel.subscribe(() => calls++), /plain data/);
|
||||
live = { selected: 'a' };
|
||||
channel.publish();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
+9
-2
@@ -43,7 +43,11 @@ export function clampBloomIntensity(value) {
|
||||
* @returns {number} equivalent v2 intensity
|
||||
*/
|
||||
export function legacyBloomToV2(value) {
|
||||
const legacy = clamp(Math.round(Number(value) || 0), 0, LEGACY_BLOOM_INTENSITY_MAX);
|
||||
const legacy = clamp(
|
||||
Math.round(Number(value) || 0),
|
||||
0,
|
||||
LEGACY_BLOOM_INTENSITY_MAX,
|
||||
);
|
||||
return clampBloomIntensity((LEGACY_BLOOM_INTENSITY_MAX - legacy) * 2);
|
||||
}
|
||||
|
||||
@@ -59,7 +63,10 @@ export function decodeBloomIntensity(value, version = 1) {
|
||||
if (!Number.isFinite(num)) return BLOOM_INTENSITY_DEFAULT;
|
||||
|
||||
const normalizedVersion = Number(version);
|
||||
if (Number.isFinite(normalizedVersion) && normalizedVersion >= BLOOM_SCALE_VERSION) {
|
||||
if (
|
||||
Number.isFinite(normalizedVersion) &&
|
||||
normalizedVersion >= BLOOM_SCALE_VERSION
|
||||
) {
|
||||
return clampBloomIntensity(num);
|
||||
}
|
||||
|
||||
|
||||
+59
-64
@@ -1,3 +1,7 @@
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { StyleManager } from './ui/applicationShell.js';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -5,14 +9,19 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
|
||||
const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8');
|
||||
const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
||||
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
||||
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
||||
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
|
||||
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
|
||||
|
||||
function body(source, pattern, label) {
|
||||
const name = pattern.source.match(/^(\w+)/)?.[1];
|
||||
if (source === ui && typeof StyleManager.prototype[name] === 'function') {
|
||||
const method = StyleManager.prototype[name].toString();
|
||||
return method.slice(method.indexOf(') {') + 3, -1);
|
||||
}
|
||||
const match = source.match(pattern);
|
||||
assert.ok(match, `${label} is missing`);
|
||||
return match[1];
|
||||
@@ -21,7 +30,7 @@ function body(source, pattern, label) {
|
||||
function ordered(source, needles, label) {
|
||||
let previous = -1;
|
||||
for (const needle of needles) {
|
||||
const index = source.indexOf(needle);
|
||||
const index = source.indexOf(needle, previous + 1);
|
||||
assert.ok(index >= 0, `${label}: missing ${needle}`);
|
||||
assert.ok(index > previous, `${label}: ${needle} is out of order`);
|
||||
previous = index;
|
||||
@@ -29,11 +38,7 @@ function ordered(source, needles, label) {
|
||||
}
|
||||
|
||||
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
||||
const enter = body(
|
||||
ui,
|
||||
/enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/,
|
||||
'Cockpit enter',
|
||||
);
|
||||
const enter = cockpitEnter.toString();
|
||||
ordered(enter, [
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
@@ -42,7 +47,7 @@ test('Cockpit takeover invalidates deferred work before camera cancellation', ()
|
||||
], 'Cockpit takeover');
|
||||
assert.match(
|
||||
ui,
|
||||
/onCameraTakeover: \(\) => this\._stampNavigation\(\{ cancelPendingSelection: false \}\),/,
|
||||
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
|
||||
'Cockpit retires stale camera work without clearing the aircraft selection it adopts',
|
||||
);
|
||||
});
|
||||
@@ -53,14 +58,14 @@ test('one explicit tracking selection clears sibling IDs before publishing its d
|
||||
/_persistAwarenessSelection\(event, cleared = false\) \{([\s\S]*?)\n \}/,
|
||||
'tracking persistence',
|
||||
);
|
||||
assert.match(persist, /adoptLayerParams\?\.\(layerId,/);
|
||||
assert.match(persist, /adoptLayerParams\?\.\(\s*layerId,/);
|
||||
assert.match(persist, /\['flights', 'selectedFlightsTrackingId'\]/);
|
||||
assert.match(persist, /\['military', 'selectedMilitaryTrackingId'\]/);
|
||||
assert.match(persist, /\['satellites', 'selectedSatTrackingId'\]/);
|
||||
assert.match(persist, /if \(otherLayerId === layerId\) continue;/);
|
||||
assert.match(
|
||||
persist,
|
||||
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(otherLayerId,[\s\S]*?adoptLayerParams\?\.\(layerId,/,
|
||||
/for \(const \[otherLayerId, otherKey\][\s\S]*?setLayerParams\(\s*otherLayerId,[\s\S]*?adoptLayerParams\?\.\(\s*layerId,/,
|
||||
'the previous family clears before Flight/Military/Satellite publishes the new durable ID',
|
||||
);
|
||||
});
|
||||
@@ -75,9 +80,9 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
|
||||
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !flightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedFlightsTrackingId: null/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !militaryFlightsLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
||||
assert.match(stamp, /if \(!passivelyClearedShareSelection && !satellitesLayer\.getTrackedInfo\?\.\(\)\)[\s\S]*?selectedSatTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
|
||||
@@ -110,7 +115,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
// Seam 1: any tracker handoff stamps, so the adoption step is covered.
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\(entity\) => \{\s*if \(entity && !this\._disposed\) this\._stampNavigation\(\{ cancelPendingSelection: false \}\);/,
|
||||
/viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{\s*if \(entity && !this\._disposed\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/,
|
||||
'tracker handoff must stamp',
|
||||
);
|
||||
// Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above.
|
||||
@@ -131,21 +136,18 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
|
||||
// is what stamps. Divergence here is how a voice-only camera path escapes
|
||||
// the arbiter.
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._listen\(this\.contextPrevious, 'click', \(\) => this\.navigateContext\(-1, \{ origin: 'user' \}\)\);/,
|
||||
CockpitViewController.toString(),
|
||||
/this\._listen\(this\.contextPrevious, 'click', \(\) =>\s*this\.navigateContext\(-1, \{ origin: 'user' \}\),?\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._listen\(this\.contextNext, 'click', \(\) => this\.navigateContext\(1, \{ origin: 'user' \}\)\);/,
|
||||
);
|
||||
const funnel = body(
|
||||
ui,
|
||||
/navigateContext\(direction, options = \{\}\) \{([\s\S]*?)\n \}\n\n \/\*\* Adopt/,
|
||||
'Cockpit Context navigation funnel',
|
||||
CockpitViewController.toString(),
|
||||
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
|
||||
);
|
||||
const funnel = navigateContext.toString();
|
||||
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
|
||||
ordered(funnel, [
|
||||
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
|
||||
'const navigationOptions = wasActive ? { ...options, aircraftOnly: true } : options;',
|
||||
'const navigationOptions = wasActive',
|
||||
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
|
||||
'this._adoptTrackedEntity(performance.now());',
|
||||
], 'Cockpit Context navigation funnel');
|
||||
@@ -243,22 +245,22 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
});
|
||||
|
||||
test('deferred search releases only after its final authority check', () => {
|
||||
const handler = body(
|
||||
ui,
|
||||
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
|
||||
'search handler',
|
||||
);
|
||||
ordered(handler, [
|
||||
"this._beginDeferredNavigation('location')",
|
||||
'this._activeLocationSearchGeneration = generation;',
|
||||
'searchAndFlyTo(this.viewer, query',
|
||||
'beforeFly: () => this._reassertNavigationHandoff(generation)',
|
||||
'generation !== this._navigationGeneration',
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'const authority = this.begin();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
'beforeFly: () => current() && this.beforeFly(authority)',
|
||||
'if (!current() || controller.signal.aborted) return;',
|
||||
'destination?.cancelled',
|
||||
'finally',
|
||||
'this._settleLocationSearchUi(generation)',
|
||||
], 'deferred search');
|
||||
assert.doesNotMatch(handler.slice(0, handler.indexOf('searchAndFlyTo')), /_releaseFollowCamera/);
|
||||
'this.onSettled?.(authority)',
|
||||
], 'deferred search component');
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
});
|
||||
|
||||
test('a direct globe gesture retires delayed camera and selection restore only', () => {
|
||||
@@ -268,15 +270,15 @@ test('a direct globe gesture retires delayed camera and selection restore only',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\('pointerdown', this\._initialShareGestureHandler/,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\('wheel', this\._initialShareGestureHandler/,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/removeEventListener\('pointerdown', this\._initialShareGestureHandler\)[\s\S]*?removeEventListener\('wheel', this\._initialShareGestureHandler\)/,
|
||||
/removeEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler,?\s*\)[\s\S]*?removeEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler,?\s*\)/,
|
||||
);
|
||||
const stamp = body(
|
||||
ui,
|
||||
@@ -300,7 +302,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
||||
'this._disposed = true;',
|
||||
'this._stampNavigation();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'await this._restoreContextSession();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'dispose invalidation');
|
||||
});
|
||||
|
||||
@@ -311,7 +313,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
|
||||
'this._removeCctvRequestFocusListener?.();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'this._navigationOwnerChangedRemover?.();',
|
||||
'await this._restoreContextSession();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'synchronous teardown barrier');
|
||||
|
||||
const navigation = body(
|
||||
@@ -337,25 +339,18 @@ test('teardown synchronously closes immediate camera entry points', () => {
|
||||
});
|
||||
|
||||
test('teardown refuses deferred location work before geocoding begins', () => {
|
||||
const deferred = body(
|
||||
ui,
|
||||
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||
'deferred navigation',
|
||||
);
|
||||
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
|
||||
assert.match(deferred, /disposed: this\._disposed/);
|
||||
|
||||
const handler = body(
|
||||
ui,
|
||||
/this\._locationSearch\.addEventListener\('keydown', async \(e\) => \{([\s\S]*?)\n \}\);/,
|
||||
'search handler',
|
||||
);
|
||||
ordered(handler, [
|
||||
"const generation = this._beginDeferredNavigation('location');",
|
||||
'if (generation === false)',
|
||||
'this._locationSearch.blur();',
|
||||
'searchAndFlyTo(this.viewer, query',
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input.blur();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
], 'disposed search refusal');
|
||||
assert.match(handler, /if \(generation === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\._activeLocationSearchGeneration/);
|
||||
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
|
||||
});
|
||||
|
||||
test('refused canned destinations commit no location or POI state', () => {
|
||||
@@ -387,7 +382,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
}
|
||||
const vesselClick = body(
|
||||
vessels,
|
||||
/handler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, Cesium\.ScreenSpaceEventType\.LEFT_CLICK\);/,
|
||||
/handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
|
||||
'vessel click',
|
||||
);
|
||||
ordered(vesselClick, [
|
||||
@@ -397,7 +392,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
], 'vessel sibling ownership');
|
||||
const vesselFocus = body(
|
||||
vessels,
|
||||
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n\}/,
|
||||
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
|
||||
'vessel focus helper',
|
||||
);
|
||||
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
||||
|
||||
@@ -73,7 +73,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur
|
||||
assert.match(diagnostics[0][0], /before setEnabled await/);
|
||||
assert.match(diagnostics[1][0], /after setEnabled await/);
|
||||
|
||||
const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
|
||||
});
|
||||
|
||||
@@ -95,3 +95,25 @@ test('CCTV disable transition does not emit enable-ownership diagnostics', async
|
||||
assert.deepEqual(transitions, [false]);
|
||||
assert.deepEqual(diagnostics, []);
|
||||
});
|
||||
|
||||
|
||||
test('a disposed UI cannot activate a camera when enabling finishes late', async () => {
|
||||
let release;
|
||||
let disposed = false;
|
||||
let activations = 0;
|
||||
const pending = runCctvLayerEnableTransition({
|
||||
target: true,
|
||||
setEnabled: () => new Promise((resolve) => { release = resolve; }),
|
||||
readOwnership: () => ({}),
|
||||
shouldFocus: () => !disposed,
|
||||
activate: () => { activations++; return 'camera-a'; },
|
||||
fly: () => assert.fail('disposed UI must not fly'),
|
||||
debug: () => {},
|
||||
});
|
||||
disposed = true;
|
||||
release();
|
||||
await pending;
|
||||
assert.equal(activations, 0);
|
||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
|
||||
});
|
||||
@@ -57,7 +57,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once
|
||||
assert.strictEqual(added[0].callback, listener);
|
||||
assert.strictEqual(removed[0].callback, added[0].callback);
|
||||
|
||||
const uiSource = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
uiSource,
|
||||
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
|
||||
|
||||
+162
-147
@@ -1,3 +1,23 @@
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||
import { readShellElements } from './ui/shellElements.js';
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { SceneControls } from './ui/sceneControls.js';
|
||||
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
|
||||
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
|
||||
import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js';
|
||||
import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js';
|
||||
import { updateContext as cockpitContext } from './ui/cockpitContext.js';
|
||||
import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js';
|
||||
import { setBriefAutoRotate } from './ui/cockpitBriefing.js';
|
||||
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _restoreContextSession } from './ui/contextSession.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -7,11 +27,11 @@ import { fileURLToPath } from 'node:url';
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
||||
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8');
|
||||
const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
|
||||
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
|
||||
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||
|
||||
test('Cockpit has one reset action beside its bottom exit path', () => {
|
||||
@@ -65,16 +85,16 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
|
||||
});
|
||||
|
||||
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
|
||||
assert.match(ui, /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(ui, /const labels = \{ optical: inherited, crt: 'CRT', nvg: 'NVG', thermal: 'FLIR', noir: 'NOIR' \};/);
|
||||
assert.doesNotMatch(ui, /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) => \([\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
|
||||
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(this\.stages, this\.transitions\);/);
|
||||
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
|
||||
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(this\.stages, next, this\._cockpitVisionRestore\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
|
||||
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
|
||||
);
|
||||
assert.match(
|
||||
@@ -92,20 +112,20 @@ test('Contacts uses the approved radar icon', () => {
|
||||
});
|
||||
|
||||
test('Cockpit Escape handling precedes form-control shortcut suppression and focus is restored', () => {
|
||||
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
const escapeIndex = keydown[1].indexOf("event.key === 'Escape'");
|
||||
const formGuardIndex = keydown[1].indexOf("closest?.('input, textarea, select, [contenteditable]')");
|
||||
const escapeIndex = keydown.indexOf("event.key === 'Escape'");
|
||||
const formGuardIndex = keydown.indexOf("closest?.('input, textarea, select, [contenteditable]')");
|
||||
assert.ok(escapeIndex >= 0 && formGuardIndex > escapeIndex, 'Escape must work while focus is inside a form control');
|
||||
|
||||
const enter = ui.match(/\n enter\(\) \{([\s\S]*?)\n \}\n\n exit\(/);
|
||||
const exit = ui.match(/\n exit\(\{ restoreTracking = true \} = \{\}\) \{([\s\S]*?)\n \}\n\n update\(\)/);
|
||||
const enter = cockpitEnter.toString();
|
||||
const exit = cockpitExit.toString();
|
||||
assert.ok(enter && exit, 'Cockpit entry/exit methods are missing');
|
||||
assert.match(enter[1], /activeElement/);
|
||||
assert.match(enter[1], /mapViewButton.*focus|focus.*mapViewButton/s);
|
||||
assert.match(exit[1], /focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(exit[1], /this\.restoreTrackingFrame\(entity\)/);
|
||||
assert.doesNotMatch(exit[1], /applyTrackedCameraFrame/);
|
||||
assert.match(enter, /activeElement/);
|
||||
assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
|
||||
assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(exit, /this\.restoreTrackingFrame\(entity\)/);
|
||||
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
|
||||
assert.match(
|
||||
ui,
|
||||
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
|
||||
@@ -114,24 +134,22 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
});
|
||||
|
||||
test('Cockpit shortcut failures do not leak and open Radio owns the first Escape', () => {
|
||||
const keydown = ui.match(/onKeyDown\(event\) \{([\s\S]*?)\n \}\n\n enter\(\)/);
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(keydown[1], /document\.getElementById\('context-radio-dock'\)\?\.classList\.contains\('disclosure-open'\)/);
|
||||
assert.match(keydown[1], /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(keydown, /document\s*\.getElementById\('context-radio-dock'\)\s*\?\.classList\.contains\('disclosure-open'\)/);
|
||||
assert.match(keydown, /#cockpit-utility-controls \[aria-expanded="true"\]/);
|
||||
assert.match(
|
||||
keydown[1],
|
||||
/const cockpitAttempt = !!\(this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the Contact panel never hides itself out from under its own NEXT button', () => {
|
||||
const updateContext = ui.match(/\n updateContext\(info, heading\) \{([\s\S]*?)\n \}\n\n scheduleContextLayout\(\)/);
|
||||
const updateContext = cockpitContext.toString();
|
||||
assert.ok(updateContext, 'Cockpit updateContext is missing');
|
||||
const body = updateContext[1];
|
||||
// ui.js cannot be imported under node (Cesium's `mgrs` dependency), so the
|
||||
// decision lives in cockpitMath.resolveCockpitContextReadout and this pins
|
||||
// ui.js to it. The panel hosts PREVIOUS/NEXT: the only reason it may hide is
|
||||
// that no snapshot exists at all.
|
||||
const body = updateContext;
|
||||
// Pin the real presentation owner to the readout policy: PREVIOUS/NEXT
|
||||
// remains reachable unless no Context snapshot exists at all.
|
||||
assert.match(body, /resolveCockpitContextReadout\(\{ snapshot, info \}\)/);
|
||||
assert.match(body, /if \(!readout\.visible\) \{[\s\S]*?this\.context\.hidden = true;/);
|
||||
assert.equal(
|
||||
@@ -164,25 +182,23 @@ test('the Contact panel never hides itself out from under its own NEXT button',
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.cockpit-context-window\[data-state="lost"\]/,
|
||||
/\.cockpit-context-window\[data-state='lost'\]/,
|
||||
'CONTACT LOST reuses the panel-level data-state cue that "uncertain" already uses',
|
||||
);
|
||||
});
|
||||
|
||||
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
|
||||
const read = ui.match(/\n readAircraftInfo\(\) \{([\s\S]*?)\n \}/);
|
||||
const read = readAircraftInfo.toString();
|
||||
assert.ok(read, 'readAircraftInfo is missing');
|
||||
assert.match(read[1], /resolveTrackedAircraftInfo\(\{/);
|
||||
assert.match(read[1], /gevTrackedId/);
|
||||
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
|
||||
assert.match(read, /gevTrackedId/);
|
||||
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
|
||||
// so its own handle is the tracked identity there.
|
||||
assert.match(read[1], /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
|
||||
assert.match(read, /this\.viewer\?\.trackedEntity \|\| this\.trackedEntity/);
|
||||
});
|
||||
|
||||
test('programmatic Context layer changes cannot bypass explicit expansion policy', () => {
|
||||
const handler = ui.match(/_handleContextLayerChange\(change\) \{([\s\S]*?)\n \}\n\n _syncContextModeButtons/);
|
||||
assert.ok(handler, 'Context layer state handler is missing');
|
||||
assert.doesNotMatch(handler[1], /setPanelCollapsed\('global-context-panel', false\)/);
|
||||
assert.doesNotMatch(_handleContextLayerChange.toString(), /setPanelCollapsed\('global-context-panel', false\)/);
|
||||
});
|
||||
|
||||
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
|
||||
@@ -227,8 +243,9 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
css,
|
||||
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
|
||||
);
|
||||
assert.match(sceneDirector, /this\._running = true;\s*document\.body\.classList\.add\('scene-playback-mode'\);/);
|
||||
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*document\.body\.classList\.remove\('scene-playback-mode'\);/);
|
||||
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
|
||||
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
|
||||
assert.match(SceneControls.prototype.setPlaybackActive.toString(), /document\.body\.classList\.toggle\('scene-playback-mode', active\)/);
|
||||
assert.equal((html.match(/id="hud-toggle"/g) || []).length, 1, 'HUD control must have one stateful DOM owner');
|
||||
assert.equal((html.match(/id="detection-toggle"/g) || []).length, 1, 'Detection control must have one stateful DOM owner');
|
||||
assert.equal((html.match(/id="models3d-toggle"/g) || []).length, 1, '3D control must have one stateful DOM owner');
|
||||
@@ -239,17 +256,17 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
const cockpitRadio = html.match(/id="cockpit-radio-panel"[\s\S]*?<\/div>\s*<\/div>\s*<\/aside>/);
|
||||
assert.ok(cockpitRadio, 'Cockpit compact Radio controls are missing');
|
||||
assert.doesNotMatch(cockpitRadio[0], /context-radio-details-btn|radio-filter|radio-tuner/);
|
||||
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(--cockpit-utility-top/);
|
||||
assert.match(css, /\[data-cockpit-launcher="display"\]\s*\{\s*width:\s*var\(--left-collapsed-width, 176px\)/);
|
||||
assert.match(css, /\.cockpit-utility-controls[\s\S]*?top:\s*var\(\s*--cockpit-utility-top/);
|
||||
assert.match(css, /\[data-cockpit-launcher='display'\]\s*\{\s*width:\s*var\(\s*--left-collapsed-width, 176px\)/);
|
||||
assert.match(css, /--display-panel-expanded-width:\s*272px/);
|
||||
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(--display-panel-expanded-width\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)\s*\{[\s\S]*?width:\s*var\(--display-panel-expanded-width\)/);
|
||||
assert.match(css, /#pp-toggles\s*\{[\s\S]*?--pp-expanded-width:\s*var\(\s*--display-panel-expanded-width\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)\s*\{[\s\S]*?width:\s*var\(\s*--display-panel-expanded-width\)/);
|
||||
assert.doesNotMatch(ui, /--cockpit-display-expanded-width|dataPanelWidth/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='display'\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, 0\.42\)/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
|
||||
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
|
||||
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
|
||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, 0\.22\);/);
|
||||
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
|
||||
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
|
||||
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
|
||||
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
|
||||
// These two widths size Cockpit Radio and nothing else. They were named for
|
||||
@@ -259,8 +276,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
assert.match(css, /--cockpit-radio-expanded-width:\s*232px/);
|
||||
assert.doesNotMatch(css, /--map-stack-(?:collapsed|expanded)-width/);
|
||||
assert.doesNotMatch(html, /id="stack-panel"/, 'the retired Map Stack panel must not remain in Cockpit layout');
|
||||
assert.match(css, /\[data-cockpit-launcher="radio"\]\s*\{\s*width:\s*var\(--cockpit-radio-collapsed-width, 148px\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="radio"\]\)[\s\S]*?--cockpit-radio-expanded-width/);
|
||||
assert.match(css, /\[data-cockpit-launcher='radio'\]\s*\{\s*width:\s*var\(\s*--cockpit-radio-collapsed-width, 148px\)/);
|
||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher='radio'\]\)[\s\S]*?--cockpit-radio-expanded-width/);
|
||||
assert.doesNotMatch(ui, /--cockpit-radio-tab-width/);
|
||||
assert.doesNotMatch(css, /\.cockpit-utility-launcher:hover/);
|
||||
const desktopUtilityCss = css.slice(
|
||||
@@ -273,8 +290,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
||||
);
|
||||
assert.match(css, /\.cockpit-utility-controls\.layout-primary-only[\s\S]*?display:\s*none/);
|
||||
assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*?\.cockpit-utility-controls:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?display:\s*none/);
|
||||
assert.match(ui, /resolveCockpitUtilityLayout\(\{ availableHeight, expandedHeight, collapsedHeight \}\)/);
|
||||
assert.match(ui, /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
|
||||
assert.match(syncSignalLayout.toString(), /resolveCockpitUtilityLayout\(\{\s*availableHeight,\s*expandedHeight,\s*collapsedHeight,?\s*\}\)/);
|
||||
assert.match(syncSignalLayout.toString(), /setAttribute\('aria-hidden', String\(hiddenSibling\)\)/);
|
||||
});
|
||||
|
||||
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => {
|
||||
@@ -286,7 +303,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
|
||||
);
|
||||
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner');
|
||||
assert.match(
|
||||
ui,
|
||||
PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
|
||||
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
|
||||
);
|
||||
assert.match(
|
||||
@@ -302,67 +319,63 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
|
||||
manager,
|
||||
/for \(const \{ layerId, intentEpoch \} of targets\)[\s\S]*?visibilityIntentEpoch !== intentEpoch[\s\S]*?await this\.setEnabled\(layerId, false/,
|
||||
);
|
||||
assert.match(ui, /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
|
||||
assert.match(ui, /async _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
|
||||
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
||||
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
||||
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
||||
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
|
||||
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
assert.match(clearSelectedLayers.toString(), /if \(this\._clearSelectedLayersPromise\) return this\._clearSelectedLayersPromise/);
|
||||
assert.match(_selectContextMode.toString(), /async function _selectContextMode\([\s\S]*?if \(this\._clearSelectedLayersPromise\) return false/);
|
||||
assert.match(clearSelectedLayers.toString(), /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
||||
assert.match(clearSelectedLayers.toString(), /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
||||
assert.match(_restoreContextSession.toString(), /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
||||
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
|
||||
assert.match(clearSelectedLayers.toString(), /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
});
|
||||
|
||||
test('Cockpit Display portal retains both scroll owners across round trips', () => {
|
||||
assert.match(ui, /this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop \|\| 0/);
|
||||
assert.match(ui, /this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop \|\| 0/);
|
||||
assert.match(ui, /if \(!this\._cockpitDisplayPortalActive\)[\s\S]*?this\._standardDisplayScrollTop = this\._ppToggles\?\.scrollTop/);
|
||||
assert.match(ui, /if \(this\._cockpitDisplayPortalActive\)[\s\S]*?this\._cockpitDisplayScrollTop = this\._cockpitDisplayPanel\?\.scrollTop/);
|
||||
assert.match(ui, /this\._cockpitDisplayPanel\.scrollTop = this\._cockpitDisplayScrollTop[\s\S]*?this\._ppToggles\.scrollTop = this\._standardDisplayScrollTop/);
|
||||
const portal = CockpitDisplayPortal.toString();
|
||||
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
|
||||
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
|
||||
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
|
||||
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
|
||||
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
|
||||
});
|
||||
|
||||
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
assert.match(
|
||||
ui,
|
||||
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
|
||||
assert.match(radioBindings,
|
||||
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
assert.match(radioBindings,
|
||||
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
assert.match(radioBindings,
|
||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel = !this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
assert.match(setContextCollapsed.toString(),
|
||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||
'Contact expansion must notify only on a collapsed-to-expanded transition',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
assert.match(setSignalCollapsed.toString(),
|
||||
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
|
||||
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
setSignalCollapsed.toString(),
|
||||
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
|
||||
radioBindings,
|
||||
/!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
|
||||
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
||||
radioBindings,
|
||||
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
||||
'left-side interactions must not collapse the independent Cockpit utilities',
|
||||
);
|
||||
});
|
||||
@@ -392,7 +405,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
);
|
||||
assert.match(
|
||||
callback[1],
|
||||
/_cockpitPanelRestore\.set\(panelId, panel\.classList\.contains\('collapsed'\)\)/,
|
||||
/_cockpitPanelRestore\.set\(\s*panelId,\s*panel\.classList\.contains\('collapsed'\),?\s*\)/,
|
||||
'entry must remember each panel\'s exact collapsed state before hiding it',
|
||||
);
|
||||
assert.match(
|
||||
@@ -437,12 +450,12 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
||||
});
|
||||
|
||||
test('real disclosure changes reconsider only their own temporary panel lane', () => {
|
||||
const collapseStart = ui.indexOf('setPanelCollapsed(panelId, collapsed, {');
|
||||
const collapseStart = ui.indexOf(' setPanelCollapsed(');
|
||||
const collapse = ui.slice(
|
||||
collapseStart,
|
||||
ui.indexOf('toggleCleanView(forceEnabled)', collapseStart),
|
||||
);
|
||||
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed && !wasAutoCollapsed[\s\S]*?return;/);
|
||||
assert.match(collapse, /classList\.contains\('collapsed'\) === nextCollapsed\s*&&\s*!wasAutoCollapsed[\s\S]*?return;/);
|
||||
assert.match(collapse, /_rightPanelStack\?\.contains\(panelEl\)[\s\S]*?_scheduleRightPanelLayout\(\{ reconsiderAutoCollapse: true \}\)/);
|
||||
assert.match(collapse, /_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/);
|
||||
});
|
||||
@@ -450,7 +463,7 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
|
||||
test('Cockpit hides the complete top-center globe action group', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\) \{ display: none !important; \}/,
|
||||
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
@@ -467,14 +480,14 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
assert.ok(resetStart >= 0);
|
||||
assert.ok(contextRelease > resetStart);
|
||||
assert.ok(satelliteRelease > contextRelease);
|
||||
assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/);
|
||||
assert.match(
|
||||
ui,
|
||||
/for \(const button of \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]\) \{[\s\S]*?addEventListener\('click', this\._globeResetHandler\)/,
|
||||
'both reset controls must delegate to the one shared reset route',
|
||||
);
|
||||
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
|
||||
assert.match(ui, /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
|
||||
assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/);
|
||||
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
|
||||
assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/);
|
||||
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
|
||||
assert.match(controls, /for \(const button of elements\.resetButtons\)[\s\S]*?this\.bind\(button, 'click', onReset\)/,
|
||||
'both reset controls delegate to the same supplied reset action');
|
||||
assert.match(cockpitEnter.toString(), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = false;/);
|
||||
assert.match(readRadioSource(new URL('./ui/cockpitTrackingController.js', import.meta.url), 'utf8'), /if \(this\.resetGlobeButton\) this\.resetGlobeButton\.hidden = true;/);
|
||||
});
|
||||
|
||||
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
|
||||
@@ -496,30 +509,34 @@ test('Location navigation releases immediate routes before flight and deferred r
|
||||
const poiHandler = ui.slice(ui.indexOf('_onPoiClick(cityId, poiIndex) {'), ui.indexOf('_expandPOIRow(cityId) {'));
|
||||
assert.match(cityHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
|
||||
assert.match(poiHandler, /if \(result === false\) return;[\s\S]*?_setActiveLocation/);
|
||||
assert.match(ui.slice(search, search + 320), /beforeFly: \(\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui.slice(search, search + 180), /\.\.\.options/);
|
||||
const lookup = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
assert.match(lookup, /beforeFly: \(\) => current\(\) && this\.beforeFly\(authority\)/);
|
||||
assert.ok(voiceStart >= 0, 'voice Location must use the same handoff');
|
||||
assert.match(voiceActions, /styleManager\.reassertDeferredLocationNavigation\(generation\)/);
|
||||
});
|
||||
|
||||
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
|
||||
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
|
||||
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
|
||||
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
||||
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
||||
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
|
||||
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
|
||||
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
|
||||
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
|
||||
assert.match(
|
||||
ui,
|
||||
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
|
||||
radioBindings,
|
||||
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
|
||||
const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
|
||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||
const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
|
||||
const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
|
||||
assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing');
|
||||
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/);
|
||||
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
|
||||
@@ -533,45 +550,45 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
|
||||
'the right margin must not borrow the left accordion corridor: it is solved '
|
||||
+ 'against left-lane obstacles and put the strip through the briefing card',
|
||||
);
|
||||
const signalLayout = ui.match(/syncSignalLayout\(\) \{([\s\S]*?)\n \}\n\n dispose\(\)/);
|
||||
const signalLayout = syncSignalLayout.toString();
|
||||
assert.ok(signalLayout, 'Cockpit signal layout method is missing');
|
||||
assert.match(
|
||||
signalLayout[1],
|
||||
/setProperty\('--cockpit-utility-top', `\$\{utilityAnchor\.top\.toFixed\(1\)\}px`\)/,
|
||||
signalLayout,
|
||||
/setProperty\(\s*'--cockpit-utility-top',\s*`\$\{utilityAnchor\.top\.toFixed\(1\)\}px`,?\s*\)/,
|
||||
'Cockpit owns the utility strip anchor and republishes it every layout tick',
|
||||
);
|
||||
assert.match(
|
||||
signalLayout[1],
|
||||
signalLayout,
|
||||
/resolveCockpitUtilityAnchor\(\{[\s\S]*?recBottom: recBounds \? recBounds\.bottom : 0,[\s\S]*?signalTop: signalBounds\.top,[\s\S]*?stripHeight: utilityBounds\.height,/,
|
||||
'the strip hangs off the REC readout and is clamped by the briefing card it shares the margin with',
|
||||
);
|
||||
assert.match(signalLayout[1], /#intel-hud \.hud-top-right/);
|
||||
assert.match(signalLayout, /#intel-hud \.hud-top-right/);
|
||||
assert.match(
|
||||
signalLayout[1],
|
||||
/const recBounds = isRenderedOnScreen\(recReadout\) \? recReadout\.getBoundingClientRect\(\) : null;/,
|
||||
signalLayout,
|
||||
/const recBounds = isRenderedOnScreen\(recReadout\)\s*\? recReadout\.getBoundingClientRect\(\)\s*: null;/,
|
||||
'HUD Off retires the Intel HUD with visibility/opacity, which leaves the REC '
|
||||
+ 'readout a rect — a rect test alone would anchor the strip to an invisible readout',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none' \|\| style\.visibility === 'hidden' \|\| Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
|
||||
isRenderedOnScreen.toString(),
|
||||
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/,
|
||||
layout,
|
||||
/_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/,
|
||||
'the strip must remeasure on the same HUD fade the accordion does — the REC '
|
||||
+ 'readout keeps its rect until the transition ends',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
signalLayout[1],
|
||||
signalLayout,
|
||||
/Math\.max\(120,/,
|
||||
'the 120px corridor floor can never be reached by a 107px strip and only hid the collision',
|
||||
);
|
||||
assert.match(signalLayout[1], /const availableHeight = utilityAnchor\.maxHeight;/);
|
||||
assert.match(signalLayout[1], /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
|
||||
assert.match(signalLayout, /const availableHeight = utilityAnchor\.maxHeight;/);
|
||||
assert.match(signalLayout, /cockpit-utility-controls[\s\S]*?--cockpit-utility-max-height/);
|
||||
assert.match(
|
||||
css,
|
||||
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(--cockpit-utility-top, var\(--left-stack-safe-top, var\(--left-stack-top\)\)\)/,
|
||||
/\.cockpit-utility-controls\s*\{[\s\S]*?top:\s*var\(\s*--cockpit-utility-top,\s*var\(\s*--left-stack-safe-top,\s*var\(\s*--left-stack-top\)\s*\)\s*\)/,
|
||||
'the first frame before Cockpit publishes an anchor still needs the fallback chain',
|
||||
);
|
||||
assert.match(css, /body\.cockpit-mode #left-panel-stack\s*\{[\s\S]*?transition:\s*none;/);
|
||||
@@ -634,26 +651,26 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
||||
);
|
||||
assert.doesNotMatch(ui, /\['presets',/);
|
||||
assert.match(
|
||||
ui,
|
||||
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)/,
|
||||
CockpitDisplayPortal.toString(),
|
||||
/group\.before\(anchor\)[\s\S]*?window\.addEventListener\(\s*'gev:cockpit-mode-changed'/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_setCockpitDisplayPortalActive\(active\) \{[\s\S]*?record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
|
||||
CockpitDisplayPortal.prototype.setActive.toString(),
|
||||
/record\.slot\.append\(record\.group\)[\s\S]*?record\.anchor\.after\(record\.group\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.removeEventListener\('gev:cockpit-mode-changed', this\._cockpitDisplayModeHandler\)[\s\S]*?_setCockpitDisplayPortalActive\(false\)[\s\S]*?record\.anchor\.remove\(\)/,
|
||||
CockpitDisplayPortal.prototype.destroy.toString(),
|
||||
/this\.stop\(\)[\s\S]*?record\.anchor\.after\(record\.group\)[\s\S]*?record\.anchor\.remove\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(ui, /_cycleCockpitHud|_cockpitModels3dToggle|_cockpitDetectionToggle/);
|
||||
assert.equal((html.match(/id="style-buttons"/g) || []).length, 1);
|
||||
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
|
||||
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
|
||||
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
|
||||
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
|
||||
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
|
||||
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
|
||||
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
|
||||
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
|
||||
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
|
||||
assert.match(cycleVisionMode.toString(), /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
|
||||
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
|
||||
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
|
||||
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{[\s\S]*?min-width:\s*0;/);
|
||||
@@ -691,14 +708,12 @@ test('cockpit route direction uses self-contained vector artwork, not a font lig
|
||||
});
|
||||
|
||||
test('cockpit aircraft handoff invalidates the prior world-position anchor', () => {
|
||||
const match = ui.match(
|
||||
/_adoptTrackedEntity\(nowMs, suppliedInfo = null\) \{([\s\S]*?)\n \}\n\n setVisionMode/,
|
||||
);
|
||||
const match = _adoptTrackedEntity.toString();
|
||||
assert.ok(match, 'cockpit tracked-entity handoff block is missing');
|
||||
assert.match(match[1], /this\.viewer\.trackedEntity = undefined;/);
|
||||
assert.match(match[1], /this\.cockpitAnchorValid = false;/);
|
||||
assert.match(match[1], /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
|
||||
assert.match(match[1], /this\.lastFrameMs = nowMs;/);
|
||||
assert.match(match, /this\.viewer\.trackedEntity = undefined;/);
|
||||
assert.match(match, /this\.cockpitAnchorValid = false;/);
|
||||
assert.match(match, /this\.heading = normalizeHeading\(info\.track \?\? 0\);/);
|
||||
assert.match(match, /this\.lastFrameMs = nowMs;/);
|
||||
});
|
||||
|
||||
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
|
||||
@@ -723,7 +738,7 @@ test('cockpit summary presents the focused item as Contact', () => {
|
||||
assert.match(match[0], /aria-label="Next — nearest unvisited contact in the 250 km window"/);
|
||||
assert.match(match[0], /aria-label="Collapse Contact panel"/);
|
||||
assert.doesNotMatch(match[0], />GLOBAL CONTEXT</);
|
||||
assert.match(ui, /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
|
||||
assert.match(setContextCollapsed.toString(), /`\$\{expanded \? 'Collapse' : 'Expand'\} Contact panel`/);
|
||||
});
|
||||
|
||||
test('Cockpit Contact navigation omits the redundant Focus camera action', () => {
|
||||
@@ -746,7 +761,9 @@ test('Global Context names its mixed contact cycle without changing the stable m
|
||||
|
||||
test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => {
|
||||
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
|
||||
assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/);
|
||||
const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
|
||||
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
|
||||
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/);
|
||||
assert.match(html, /id="global-context-panel"/);
|
||||
assert.match(html, /id="global-context-flights-btn"/);
|
||||
assert.match(html, /id="global-context-missions-btn"/);
|
||||
@@ -771,14 +788,12 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
|
||||
assert.match(match[0], />CYCLE OFF<\/button>/);
|
||||
assert.match(match[0], /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./);
|
||||
|
||||
const update = ui.match(
|
||||
/setBriefAutoRotate\(enabled\) \{([\s\S]*?)\n \}\n\n startBriefRotation/,
|
||||
);
|
||||
const update = setBriefAutoRotate.toString();
|
||||
assert.ok(update, 'cockpit briefing cycle state updater is missing');
|
||||
assert.match(update[1], /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
|
||||
assert.match(update[1], /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update[1], /\.title = help;/);
|
||||
assert.doesNotMatch(update[1], /setAttribute\('aria-label', help\)/);
|
||||
assert.match(update, /const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/);
|
||||
assert.match(update, /setAttribute\('aria-label', label\)/);
|
||||
assert.match(update, /\.title = help;/);
|
||||
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry honours a requested contact layer before it enters', () => {
|
||||
@@ -845,7 +860,7 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(
|
||||
state,
|
||||
/const entryAllowed = !active && Boolean\(/,
|
||||
/const entryAllowed =\s*!active &&\s*Boolean\(/,
|
||||
'entry is impossible while already inside, unconditionally',
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
|
||||
+76
-36
@@ -25,8 +25,13 @@ export function slewHeading(current, target, maxStepDeg) {
|
||||
* becoming a first-person surge or reversal while still removing drift.
|
||||
*/
|
||||
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
if (!Number.isFinite(distanceM) || distanceM <= 0
|
||||
|| !Number.isFinite(dtSec) || dtSec <= 0) return 0;
|
||||
if (
|
||||
!Number.isFinite(distanceM) ||
|
||||
distanceM <= 0 ||
|
||||
!Number.isFinite(dtSec) ||
|
||||
dtSec <= 0
|
||||
)
|
||||
return 0;
|
||||
const dt = Math.min(0.1, dtSec);
|
||||
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
|
||||
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
|
||||
@@ -36,14 +41,21 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
|
||||
|
||||
/** Return whether a throttled cockpit presentation update is due. */
|
||||
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) return false;
|
||||
if (!Number.isFinite(lastUpdateMs) || lastUpdateMs <= 0 || nowMs < lastUpdateMs) return true;
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
|
||||
return false;
|
||||
if (
|
||||
!Number.isFinite(lastUpdateMs) ||
|
||||
lastUpdateMs <= 0 ||
|
||||
nowMs < lastUpdateMs
|
||||
)
|
||||
return true;
|
||||
return nowMs - lastUpdateMs >= intervalMs;
|
||||
}
|
||||
|
||||
/** Return whether the bounded rendered-surface acquisition window has elapsed. */
|
||||
export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
||||
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0) return true;
|
||||
if (![nowMs, startedMs, timeoutMs].every(Number.isFinite) || timeoutMs < 0)
|
||||
return true;
|
||||
return nowMs - startedMs >= timeoutMs;
|
||||
}
|
||||
|
||||
@@ -51,7 +63,11 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) {
|
||||
* Keep the cockpit camera above the shared rendered-surface floor.
|
||||
* Unknown floors preserve the proposed camera height.
|
||||
*/
|
||||
export function cockpitGroundSafeHeight(proposedHeightM, groundHeightM, clearanceM) {
|
||||
export function cockpitGroundSafeHeight(
|
||||
proposedHeightM,
|
||||
groundHeightM,
|
||||
clearanceM,
|
||||
) {
|
||||
if (!Number.isFinite(proposedHeightM)) return proposedHeightM;
|
||||
if (!Number.isFinite(groundHeightM)) return proposedHeightM;
|
||||
const clearance = Number.isFinite(clearanceM) ? Math.max(0, clearanceM) : 0;
|
||||
@@ -68,36 +84,51 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
||||
}
|
||||
|
||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||
export function formatCockpitContextScope(subjectLabel, radiusM, installationCoverage = null) {
|
||||
const normalizedLabel = typeof subjectLabel === 'string'
|
||||
? subjectLabel.trim() || '—'
|
||||
: Number.isFinite(subjectLabel) ? String(subjectLabel) : '—';
|
||||
const radiusKm = Number.isFinite(radiusM) && radiusM >= 0
|
||||
? String(Math.round(radiusM / 1000))
|
||||
: '—';
|
||||
const coverage = typeof installationCoverage === 'string'
|
||||
? installationCoverage.trim()
|
||||
: '';
|
||||
export function formatCockpitContextScope(
|
||||
subjectLabel,
|
||||
radiusM,
|
||||
installationCoverage = null,
|
||||
) {
|
||||
const normalizedLabel =
|
||||
typeof subjectLabel === 'string'
|
||||
? subjectLabel.trim() || '—'
|
||||
: Number.isFinite(subjectLabel)
|
||||
? String(subjectLabel)
|
||||
: '—';
|
||||
const radiusKm =
|
||||
Number.isFinite(radiusM) && radiusM >= 0
|
||||
? String(Math.round(radiusM / 1000))
|
||||
: '—';
|
||||
const coverage =
|
||||
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
||||
return coverage
|
||||
? `${base} · INSTALLATIONS ${coverage}`
|
||||
: base;
|
||||
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
|
||||
}
|
||||
|
||||
/** Return seven 30-degree compass divisions centered on a heading. */
|
||||
export function compassDivisions(heading) {
|
||||
const center = Math.round(normalizeHeading(heading) / 30) * 30;
|
||||
return [-90, -60, -30, 0, 30, 60, 90].map((offset) => normalizeHeading(center + offset));
|
||||
return [-90, -60, -30, 0, 30, 60, 90].map((offset) =>
|
||||
normalizeHeading(center + offset),
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a compass division as a cardinal/intercardinal label or degrees. */
|
||||
export function formatCompassDivision(heading) {
|
||||
const normalized = normalizeHeading(heading);
|
||||
const labels = new Map([
|
||||
[0, 'N'], [45, 'NE'], [90, 'E'], [135, 'SE'],
|
||||
[180, 'S'], [225, 'SW'], [270, 'W'], [315, 'NW'],
|
||||
[0, 'N'],
|
||||
[45, 'NE'],
|
||||
[90, 'E'],
|
||||
[135, 'SE'],
|
||||
[180, 'S'],
|
||||
[225, 'SW'],
|
||||
[270, 'W'],
|
||||
[315, 'NW'],
|
||||
]);
|
||||
return labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0');
|
||||
return (
|
||||
labels.get(normalized) || String(Math.round(normalized)).padStart(3, '0')
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose a readable altitude-tape interval for the current flight level. */
|
||||
@@ -190,20 +221,22 @@ export function formatSpeedRulerTick(valueKt) {
|
||||
/** Return the initial great-circle bearing between two latitude/longitude points. */
|
||||
export function bearingBetweenCoordinates(fromLat, fromLon, toLat, toLon) {
|
||||
if (![fromLat, fromLon, toLat, toLon].every(Number.isFinite)) return null;
|
||||
const φ1 = fromLat * Math.PI / 180;
|
||||
const φ2 = toLat * Math.PI / 180;
|
||||
const Δλ = (toLon - fromLon) * Math.PI / 180;
|
||||
const φ1 = (fromLat * Math.PI) / 180;
|
||||
const φ2 = (toLat * Math.PI) / 180;
|
||||
const Δλ = ((toLon - fromLon) * Math.PI) / 180;
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2)
|
||||
- Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||
const x =
|
||||
Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||
if (Math.abs(x) < 1e-12 && Math.abs(y) < 1e-12) return null;
|
||||
return normalizeHeading(Math.atan2(y, x) * 180 / Math.PI);
|
||||
return normalizeHeading((Math.atan2(y, x) * 180) / Math.PI);
|
||||
}
|
||||
|
||||
/** Return a target bearing relative to the current heading in the [-180, 180) range. */
|
||||
export function relativeBearing(bearing, heading) {
|
||||
if (!Number.isFinite(bearing) || !Number.isFinite(heading)) return null;
|
||||
return ((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180;
|
||||
return (
|
||||
((normalizeHeading(bearing) - normalizeHeading(heading) + 540) % 360) - 180
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,10 +262,13 @@ export function resolveTrackedAircraftInfo({
|
||||
if (civilian) candidates.push({ ...civilian, layerId: 'flights' });
|
||||
if (military) candidates.push({ ...military, layerId: 'military' });
|
||||
if (!candidates.length) return null;
|
||||
const trackedKey = String(trackedId || '').trim().toLowerCase();
|
||||
const trackedKey = String(trackedId || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (trackedKey) {
|
||||
const owner = candidates.find(
|
||||
(candidate) => `${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
|
||||
(candidate) =>
|
||||
`${candidate.layerId}:${candidate.icao24}`.toLowerCase() === trackedKey,
|
||||
);
|
||||
if (owner) return owner;
|
||||
}
|
||||
@@ -253,7 +289,10 @@ export function resolveTrackedAircraftInfo({
|
||||
* aircraftRelative: boolean, contactLost: boolean}}
|
||||
* Panel visibility plus the per-field rendering rules for this frame.
|
||||
*/
|
||||
export function resolveCockpitContextReadout({ snapshot = null, info = null } = {}) {
|
||||
export function resolveCockpitContextReadout({
|
||||
snapshot = null,
|
||||
info = null,
|
||||
} = {}) {
|
||||
if (!snapshot) {
|
||||
return {
|
||||
visible: false,
|
||||
@@ -264,14 +303,15 @@ export function resolveCockpitContextReadout({ snapshot = null, info = null } =
|
||||
};
|
||||
}
|
||||
const trackedId = info?.icao24 ?? info?.id;
|
||||
const subjectMatchesTracked = snapshot.subject?.layerId === info?.layerId
|
||||
&& String(snapshot.subject?.id) === String(trackedId);
|
||||
const subjectMatchesTracked =
|
||||
snapshot.subject?.layerId === info?.layerId &&
|
||||
String(snapshot.subject?.id) === String(trackedId);
|
||||
// `subjectPresent` is absent on snapshots built before the presence signal
|
||||
// existed; only an explicit `false` means the subject left its source.
|
||||
const contactLost = snapshot.subjectPresent === false;
|
||||
return {
|
||||
visible: true,
|
||||
mode: contactLost ? 'lost' : (subjectMatchesTracked ? 'tracked' : 'foreign'),
|
||||
mode: contactLost ? 'lost' : subjectMatchesTracked ? 'tracked' : 'foreign',
|
||||
subjectMatchesTracked,
|
||||
// The nose-relative arrow and BRG readout are measured in the tracked
|
||||
// aircraft's own frame; every other value in that row is measured from the
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach } from 'node:test';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
|
||||
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const renderStart = source.indexOf(' renderCockpitSignals() {');
|
||||
const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
|
||||
assert.ok(renderStart >= 0 && renderEnd > renderStart);
|
||||
const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
|
||||
assert.ok(clickSource, 'the installed signal click route exists');
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
const previousDocument = globalThis.document;
|
||||
afterEach(() => { globalThis.document = previousDocument; });
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
@@ -84,8 +80,9 @@ function fixture() {
|
||||
document.body = new Node('body');
|
||||
document.createElement = (tag) => new Node(tag);
|
||||
document.activeElement = document.body;
|
||||
const controller = new (new Function('document', 'formatAwarenessLabel',
|
||||
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
|
||||
globalThis.document = document;
|
||||
const controller = Object.create(CockpitViewController.prototype);
|
||||
controller.services = { formatAwarenessLabel };
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
|
||||
@@ -98,9 +95,8 @@ function fixture() {
|
||||
const outside = new Node('button');
|
||||
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
|
||||
const selected = [];
|
||||
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
});
|
||||
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
|
||||
const click = event => controller.handleSignalClick(event);
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
|
||||
@@ -62,7 +62,10 @@ export function resolveCockpitUtilityLayout({
|
||||
gap = 7,
|
||||
minimumExpandedHeight = 120,
|
||||
}) {
|
||||
const available = Math.max(minimumExpandedHeight, Number(availableHeight) || 0);
|
||||
const available = Math.max(
|
||||
minimumExpandedHeight,
|
||||
Number(availableHeight) || 0,
|
||||
);
|
||||
const expanded = Math.max(0, Number(expandedHeight) || 0);
|
||||
const collapsed = Math.max(0, Number(collapsedHeight) || 0);
|
||||
const spacing = Math.max(0, Number(gap) || 0);
|
||||
@@ -76,4 +79,3 @@ export function resolveCockpitUtilityLayout({
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export const COCKPIT_VISION_MODES = Object.freeze(['optical', 'crt', 'nvg', 'thermal', 'noir']);
|
||||
export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'optical',
|
||||
'crt',
|
||||
'nvg',
|
||||
'thermal',
|
||||
'noir',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
crt: 'retro',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
|
||||
// Contacts, detections should just turn on, and they should stay on in Cockpit
|
||||
// or in third-person tracking inside Contacts").
|
||||
@@ -26,7 +27,9 @@ import {
|
||||
} from './data/detection.js';
|
||||
import { canonicalizeDensity } from './data/detectionPolicy.js';
|
||||
|
||||
const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
// Follow the UI wiring and its extracted preset definitions.
|
||||
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
|
||||
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
|
||||
|
||||
/**
|
||||
* The tactical preset ui.js hands Contacts. Read out of the source so this test
|
||||
@@ -34,7 +37,7 @@ const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
*/
|
||||
const MILITARY_PRESET = (() => {
|
||||
const match = uiSource.match(
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: '(\w+)', densityPct: (\d+) \}\)/,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: '(\w+)',\s*densityPct: (\d+),?\s*\}\)/,
|
||||
);
|
||||
assert.ok(match, 'ui.js must expose one shared military detection preset');
|
||||
return { mode: match[1].toUpperCase(), densityPct: Number(match[2]) };
|
||||
@@ -290,10 +293,11 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc
|
||||
'ACTIVATION turns detection on REGARDLESS of the style-preset override flag',
|
||||
);
|
||||
assert.match(
|
||||
uiSource,
|
||||
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\._syncContactsDetection\(\);/,
|
||||
_syncContextModeButtons.toString(),
|
||||
/this\.cockpitView\?\.syncEntry\(\);[\s\S]{0,220}?this\.actions\.syncDetection\(\);/,
|
||||
'called from _syncContextModeButtons, the funnel every _contextMode mutation routes through',
|
||||
);
|
||||
assert.match(uiSource, /syncDetection: \(\) => this\._syncContactsDetection\(\)/);
|
||||
// The cockpit vision hook — the old trigger — must be out of the detection
|
||||
// business entirely, or leaving the cockpit turns detections off again.
|
||||
const visionHook = uiSource.slice(
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// Source-contract pins for StyleManager's Context-session handler ordering.
|
||||
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
|
||||
// these read the shipped source — the idiom established by cockpitMarkup /
|
||||
// cameraHandoff tests. Each pin guards a bug that shipped or nearly shipped:
|
||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||
import { connectContextManager } from './ui/contextSubscriptions.js';
|
||||
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
|
||||
import { _selectContextMode, _clearLayersOutsideContextMode } from './ui/contextTransactions.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { setContextMode as contextModeAction } from './ui/contextActions.js';
|
||||
import { ContextControls } from './ui/contextControls.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
// Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped:
|
||||
// - session bookkeeping ran AFTER the exit early-return, so the compensating
|
||||
// userAdded.delete never ran on the left-panel chip exit and restoration
|
||||
// resurrected the mission layer the user just disabled;
|
||||
@@ -15,12 +23,9 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
|
||||
const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
|
||||
|
||||
const handlerStart = src.indexOf('_handleContextLayerChange(change) {');
|
||||
assert.ok(handlerStart > 0, 'handler found');
|
||||
const handlerEnd = src.indexOf('_syncContextModeButtons() {', handlerStart);
|
||||
const handler = src.slice(handlerStart, handlerEnd);
|
||||
const handler = _handleContextLayerChange.toString();
|
||||
|
||||
test('context handler: session bookkeeping runs before the exit early-return', () => {
|
||||
const record = handler.indexOf('recordContextSessionUserChange(');
|
||||
@@ -52,23 +57,19 @@ test('cancelled Space Missions entry resolves ownership before settled bookkeepi
|
||||
});
|
||||
|
||||
test('direct Context entry captures on the synchronous request boundary', () => {
|
||||
const attachStart = src.indexOf('attachDataManager(dataManager) {');
|
||||
const attachEnd = src.indexOf('_syncContextModeButtons()', attachStart);
|
||||
const attach = src.slice(attachStart, attachEnd);
|
||||
const attach = connectContextManager.toString();
|
||||
assert.match(
|
||||
attach,
|
||||
/subscribeVisibilityRequests\(\(change\) => \{[\s\S]*?shouldCaptureContextSession\(change\)/,
|
||||
);
|
||||
assert.match(
|
||||
attach,
|
||||
/_captureContextSessionSnapshot\(\{ excludeLayerIds: \[change\.layerId\] \}\)/,
|
||||
/_captureContextSessionSnapshot\(\{\s*excludeLayerIds:\s*\[change\.layerId\],?\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Clear All defers a newer explicit mission guard until its batch settles', () => {
|
||||
const attachStart = src.indexOf('attachDataManager(dataManager) {');
|
||||
const attachEnd = src.indexOf('if (typeof this._dataManager?.subscribeBeforeDestroy', attachStart);
|
||||
const attach = src.slice(attachStart, attachEnd);
|
||||
const attach = connectContextManager.toString();
|
||||
assert.match(attach, /shouldDeferContextEntryDuringClear\(\{/);
|
||||
assert.match(attach, /_contextModeDeferredEntryIntent = \{/);
|
||||
assert.match(attach, /await this\._clearSelectedLayersManagerPromise/);
|
||||
@@ -76,10 +77,7 @@ test('Clear All defers a newer explicit mission guard until its batch settles',
|
||||
});
|
||||
|
||||
test('context restore replays explicit companion intent after the stale restore queue drains', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
const restoreAwait = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(');
|
||||
const replay = restore.indexOf('await settleContextIntentReplay(');
|
||||
assert.ok(restoreAwait > 0, 'restore queue is awaited');
|
||||
@@ -88,10 +86,7 @@ test('context restore replays explicit companion intent after the stale restore
|
||||
});
|
||||
|
||||
test('context restore settles the Contacts coordinator before dependency fanout', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
const settle = restore.indexOf("const contactsCoordinatorId = 'military-awareness'");
|
||||
const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled(', settle);
|
||||
const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', settle);
|
||||
@@ -102,10 +97,7 @@ test('context restore settles the Contacts coordinator before dependency fanout'
|
||||
});
|
||||
|
||||
test('context restore preserves its exact pending target after a failed transition', () => {
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/);
|
||||
assert.match(restore, /if \(restoreError && !this\._contextSessionSnapshot\)/);
|
||||
assert.match(restore, /enabledLayerIds: new Set\(restoreState\.enabledLayerIds\)/);
|
||||
@@ -120,25 +112,22 @@ test('context handler: either failed direct Context-shell start rolls the sessio
|
||||
assert.match(failedBranch, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
|
||||
assert.match(
|
||||
failedBranch,
|
||||
/_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{ notificationToken \},\s*\)/,
|
||||
/_restoreContextSessionAfterLayerSettles\(\s*change\.layerId,\s*\{\s*notificationToken,?\s*\},?\s*\)/,
|
||||
);
|
||||
assert.match(failedBranch, /_runUserFacingContextAction/);
|
||||
assert.match(failedBranch, /const failureMessage =/);
|
||||
assert.match(
|
||||
failedBranch,
|
||||
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\._showToast\(failureMessage\);\s*return true;/,
|
||||
/await this\._restoreContextSessionAfterLayerSettles\([\s\S]*?this\.showToast\(failureMessage\);\s*return true;/,
|
||||
);
|
||||
assert.match(failedBranch, /\},\s*failureMessage,\s*\)\);/);
|
||||
assert.match(failedBranch, /\},\s*failureMessage,?\s*\),?\s*\);/);
|
||||
assert.match(failedBranch, /_trackContextLayerReaction\(/);
|
||||
assert.match(
|
||||
failedBranch,
|
||||
/else if \(!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\)/,
|
||||
/else if \(\s*!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)\s*\)/,
|
||||
);
|
||||
|
||||
const deferredRestore = src.slice(
|
||||
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
|
||||
src.indexOf('async _selectContextMode('),
|
||||
);
|
||||
const deferredRestore = _restoreContextSessionAfterLayerSettles.toString();
|
||||
assert.match(deferredRestore, /await this\._dataManager\?\.waitForLayerSettled\?\.\(layerId\)/);
|
||||
assert.match(deferredRestore, /return this\._restoreContextSession\(\{ notificationToken \}\)/);
|
||||
assert.doesNotMatch(failedBranch, /excludeLayerIds/);
|
||||
@@ -153,50 +142,45 @@ test('wrapped visibility blocks leave the accessible toast to the wrapper token
|
||||
blockedBranch,
|
||||
/!this\._userFacingContextNotificationTokens\.has\(change\.notificationToken\)/,
|
||||
);
|
||||
assert.match(blockedBranch, /this\._showToast\(/);
|
||||
assert.match(blockedBranch, /this\.showToast\(/);
|
||||
});
|
||||
|
||||
test('every user-facing Context exit route settles through the failure surface', () => {
|
||||
const initPanel = src.slice(
|
||||
src.indexOf('_initGlobalContextPanel() {'),
|
||||
src.indexOf('_captureContextSessionSnapshot(', src.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const initPanel = _initGlobalContextPanel.toString();
|
||||
assert.equal((initPanel.match(/void this\._runUserFacingContextAction/g) || []).length, 3);
|
||||
assert.doesNotMatch(initPanel, /falseIsFailure:\s*false/);
|
||||
assert.doesNotMatch(initPanel, /void this\._selectContextMode/);
|
||||
|
||||
const deactivationCalls = [...handler.matchAll(
|
||||
/void this\._trackContextLayerReaction\(this\._runUserFacingContextAction\(\(notificationToken\) => \(\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\)\s*\)\)\)/g,
|
||||
/void this\._trackContextLayerReaction\(\s*this\._runUserFacingContextAction\(\(notificationToken\) =>\s*this\._deactivateContextForLayerChange\(\{ notificationToken \}\),?\s*\),?\s*\)/g,
|
||||
)];
|
||||
assert.equal(deactivationCalls.length, 4, 'dependency and primary layer exits share the caught restore path');
|
||||
assert.doesNotMatch(handler, /void this\._deactivateContextForLayerChange\(\)/);
|
||||
});
|
||||
|
||||
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
|
||||
const radioControls = src.slice(
|
||||
src.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
src.indexOf("this._radioFilter?.addEventListener('change'"),
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
);
|
||||
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
|
||||
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
|
||||
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
|
||||
assert.match(radioControls, /if \(toggled === false\) return/);
|
||||
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
|
||||
});
|
||||
|
||||
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
|
||||
const radioControls = src.slice(
|
||||
src.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
src.indexOf("this._radioFilter?.addEventListener('change'"),
|
||||
const radioControls = radioBindings.slice(
|
||||
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||
);
|
||||
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
|
||||
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
|
||||
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
|
||||
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||
});
|
||||
|
||||
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
const select = _selectContextMode.toString();
|
||||
const activation = select.indexOf('activationIntent = this._dataManager._setEnabledWithIntent(');
|
||||
assert.ok(activation > 0, 'activation result captured');
|
||||
const failureBlock = select.slice(activation);
|
||||
@@ -206,8 +190,8 @@ test('right-rail context entry is transactional: activation result gates the mod
|
||||
assert.match(failureBlock, /await this\._dataManager\._waitForVisibilityIntent\?\.\(/);
|
||||
assert.match(failureBlock, /outcome\?\.intentEpoch === replacementIntent\.intentEpoch[\s\S]*?outcome\.succeeded === true/);
|
||||
assert.match(failureBlock, /outcome\?\.cancellationReason === 'superseded'[\s\S]*?outcome\.successorEnabled === true[\s\S]*?outcome\.successorIntentEpoch > replacementIntent\.intentEpoch/);
|
||||
assert.match(failureBlock, /activationError \|\| activated === false \|\| !this\._dataManager\.isEnabled\(entryLayerId\)/);
|
||||
assert.match(failureBlock, /cancelledAndSettled = terminalIntentOutcome\?\.succeeded === false/);
|
||||
assert.match(failureBlock, /activationError\s*\|\|\s*activated === false\s*\|\|\s*!this\._dataManager\.isEnabled\(entryLayerId\)/);
|
||||
assert.match(failureBlock, /cancelledAndSettled\s*=\s*terminalIntentOutcome\?\.succeeded === false/);
|
||||
assert.match(failureBlock, /\['caller-abort', 'resource-abort', 'superseded'\]/);
|
||||
assert.match(failureBlock, /this\._contextMode = null/);
|
||||
assert.match(failureBlock, /await this\._restoreContextSession\(\{[\s\S]*?excludeLayerIds: \[entryLayerId\],[\s\S]*?notificationToken/);
|
||||
@@ -215,11 +199,8 @@ test('right-rail context entry is transactional: activation result gates the mod
|
||||
});
|
||||
|
||||
test('Context entry awaits isolation and direct shell routes isolate in the visibility guard', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode, \{ notificationToken, signal \}\)/);
|
||||
const select = _selectContextMode.toString();
|
||||
assert.match(select, /await this\._clearLayersOutsideContextMode\(mode,\s*\{\s*notificationToken,\s*signal,?\s*\}\)/);
|
||||
assert.match(select, /await this\._restoreContextSession\(\{ notificationToken, signal \}\)/);
|
||||
assert.doesNotMatch(select, /void this\._clearLayersOutsideContextMode\(mode\)/);
|
||||
|
||||
@@ -250,21 +231,19 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
|
||||
// invalidates in-flight Context transactions so teardown cannot be raced into
|
||||
// publishing a mode.
|
||||
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {')));
|
||||
assert.match(dispose, /this\._contextModeGeneration \+= 1;/);
|
||||
assert.match(dispose, /this\._contextControls\.stop\(\)/);
|
||||
assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/);
|
||||
|
||||
const guard = src.slice(
|
||||
src.indexOf('this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard'),
|
||||
src.indexOf("if (typeof this._dataManager?.subscribeBeforeDestroy === 'function')"),
|
||||
);
|
||||
const guard = connectContextManager.toString();
|
||||
assert.match(guard, /\['military-awareness', 'rocket-launches'\]\.includes\(change\.layerId\)/);
|
||||
assert.match(guard, /const notificationToken = change\.notificationToken \|\| Symbol\('direct-context-shell-entry'\)/);
|
||||
assert.match(guard, /const notificationToken\s*=\s*change\.notificationToken\s*\|\|\s*Symbol\('direct-context-shell-entry'\)/);
|
||||
assert.match(guard, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||
assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode, \{ notificationToken \}\)/);
|
||||
assert.match(guard, /await this\._clearLayersOutsideContextMode\(entryMode,\s*\{\s*notificationToken,?\s*\}\)/);
|
||||
assert.match(
|
||||
guard,
|
||||
/await this\._restoreContextSession\(\{\s*excludeLayerIds: \[change\.layerId\],\s*notificationToken,\s*\}\)/,
|
||||
);
|
||||
assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(notificationToken\)/);
|
||||
assert.match(guard, /this\._userFacingContextNotificationTokens\.delete\(\s*notificationToken,?\s*\)/);
|
||||
});
|
||||
|
||||
test('a lost cross-mode switch says Context is off, and the state agrees', () => {
|
||||
@@ -272,14 +251,11 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
|
||||
// a bare "did not complete" while the operator's Context was silently gone.
|
||||
// Text and state are derived from the same verdict so they cannot disagree,
|
||||
// and the failed layer ids survive (Manjunath's honesty requirement).
|
||||
const setter = src.slice(
|
||||
src.indexOf(' async setContextMode(mode, {'),
|
||||
src.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
const setter = contextModeAction.toString();
|
||||
assert.match(setter, /const priorMode = this\._contextMode;/, 'the report knows what was lost');
|
||||
assert.match(
|
||||
setter,
|
||||
/const crossModeSwitchLost = transitioned !== true\s*&& Boolean\(priorMode\) && priorMode !== canonical && !state\.mode;/,
|
||||
/const crossModeSwitchLost\s*=\s*transitioned !== true\s*&&\s*Boolean\(priorMode\)\s*&&\s*priorMode !== canonical\s*&&\s*!state\.mode;/,
|
||||
'the verdict requires a real cross-mode switch that ended with no mode',
|
||||
);
|
||||
assert.match(
|
||||
@@ -296,14 +272,10 @@ test('a lost cross-mode switch says Context is off, and the state agrees', () =>
|
||||
});
|
||||
|
||||
test('Context cancellation reaches isolation and restore lifecycle mutations', () => {
|
||||
const clearStart = src.indexOf('async _clearLayersOutsideContextMode(');
|
||||
const clear = src.slice(clearStart, src.indexOf('_handleContextLayerChange(', clearStart));
|
||||
const clear = _clearLayersOutsideContextMode.toString();
|
||||
assert.match(clear, /setEnabled\(layerId, false, \{[\s\S]*?signal/);
|
||||
|
||||
const restore = src.slice(
|
||||
src.indexOf('async _restoreContextSession('),
|
||||
src.indexOf('async _restoreContextSessionAfterLayerSettles('),
|
||||
);
|
||||
const restore = _restoreContextSession.toString();
|
||||
assert.match(restore, /restoreEnabledLayerIds\([\s\S]*?signal/);
|
||||
assert.match(restore, /setEnabled: \(layerId, enabled, options = \{\}\)[\s\S]*?signal/);
|
||||
assert.match(restore, /error\.failedLayerIds = \[contactsCoordinatorId\]/);
|
||||
@@ -314,10 +286,7 @@ test('Context cancellation reaches isolation and restore lifecycle mutations', (
|
||||
});
|
||||
|
||||
test('Context facade preserves success when cancellation arrives after commit', () => {
|
||||
const setContextMode = src.slice(
|
||||
src.indexOf('async setContextMode('),
|
||||
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
|
||||
);
|
||||
const setContextMode = contextModeAction.toString();
|
||||
assert.match(
|
||||
setContextMode,
|
||||
/transitioned === null \|\| \(!requestIsCurrent\(\) && transitioned !== true\)/,
|
||||
@@ -329,23 +298,17 @@ test('Context facade preserves success when cancellation arrives after commit',
|
||||
});
|
||||
|
||||
test('Context production rollback paths merge primary and restore failed-layer identities', () => {
|
||||
const select = src.slice(
|
||||
src.indexOf('async _selectContextMode('),
|
||||
src.indexOf('async _deactivateContextForLayerChange('),
|
||||
);
|
||||
const select = _selectContextMode.toString();
|
||||
assert.equal(
|
||||
(select.match(/mergeContextTransitionErrors\(transitionError, restoreError\)/g) || []).length,
|
||||
(select.match(/mergeContextTransitionErrors\(\s*transitionError,\s*restoreError,?\s*\)/g) || []).length,
|
||||
2,
|
||||
);
|
||||
assert.match(select, /transitionError\.failedLayerIds = \[\.\.\.new Set\(\[/);
|
||||
assert.match(select, /this\._contextTransitionFailedLayerIds = \[\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\)\]/);
|
||||
assert.match(select, /transitionError\.failedLayerIds = \[\s*\.\.\.new Set\(\[/);
|
||||
assert.match(select, /this\._contextTransitionFailedLayerIds = \[\s*\.\.\.\(transitionError\.failedLayerIds \|\| \[\]\),?\s*\]/);
|
||||
});
|
||||
|
||||
test('stale Context cancellation preserves rollback failed-layer identities', () => {
|
||||
const setContextMode = src.slice(
|
||||
src.indexOf('async setContextMode('),
|
||||
src.indexOf('getCockpitState()', src.indexOf('async setContextMode(')),
|
||||
);
|
||||
const setContextMode = contextModeAction.toString();
|
||||
assert.match(
|
||||
setContextMode,
|
||||
/if \(!requestIsCurrent\(\)\) \{[\s\S]*?\.\.\.cancellationResult\(\),[\s\S]*?failedLayerIds: \[\.\.\.error\.failedLayerIds\]/,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||
import { clearSelectedLayers } from './ui/contextActions.js';
|
||||
import { readFileSync as readRadioSource } from 'node:fs';
|
||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
|
||||
@@ -14,16 +23,14 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence'
|
||||
assert.match(button[0], /tabindex="0"/);
|
||||
}
|
||||
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
|
||||
assert.match(sync[1], /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
assert.match(syncSource, /\[\s*this\._globalContextFlightsBtn,\s*this\._globalContextMissionsBtn,?\s*\]/);
|
||||
assert.match(syncSource, /button\.tabIndex = 0/);
|
||||
assert.doesNotMatch(syncSource, /tabIndex\s*=\s*[^;]*\?\s*-1/);
|
||||
});
|
||||
|
||||
test('Context transition state preserves focus and Tab availability until settle', () => {
|
||||
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
|
||||
assert.ok(sync, 'Context mode sync is missing');
|
||||
const syncSource = _syncContextModeButtons.toString();
|
||||
|
||||
const attributes = () => new Map();
|
||||
const makeButton = () => {
|
||||
@@ -52,6 +59,8 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
const priorDocument = globalThis.document;
|
||||
globalThis.document = { activeElement: missions, getElementById: () => panel };
|
||||
const owner = {
|
||||
_globalContextPanel: panel,
|
||||
actions: { syncDetection() {}, scheduleLayout() {} },
|
||||
_contextMode: null,
|
||||
_contextModeChanging: true,
|
||||
_globalContextFlightsBtn: contacts,
|
||||
@@ -64,7 +73,7 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
_scheduleRightPanelLayout() {},
|
||||
};
|
||||
try {
|
||||
Function(sync[1]).call(owner);
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
@@ -75,7 +84,7 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
|
||||
owner._contextModeChanging = false;
|
||||
owner._contextMode = 'space-missions';
|
||||
Function(sync[1]).call(owner);
|
||||
_syncContextModeButtons.call(owner);
|
||||
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
|
||||
for (const button of [contacts, missions]) {
|
||||
assert.equal(button.disabled, false);
|
||||
@@ -89,32 +98,22 @@ test('Context transition state preserves focus and Tab availability until settle
|
||||
});
|
||||
|
||||
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const select = ui.slice(
|
||||
ui.indexOf('async _selectContextMode('),
|
||||
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
|
||||
);
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const select = _selectContextMode.toString();
|
||||
const clear = clearSelectedLayers.toString();
|
||||
assert.equal((init.match(/if \(\s*this\.destroyed\s*\|\|\s*this\._contextModeChanging\s*\|\|\s*this\._clearSelectedLayersPromise\s*\)\s*return;/g) || []).length, 2);
|
||||
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
|
||||
});
|
||||
|
||||
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
|
||||
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
|
||||
assert.ok(init, 'Context panel initialization is missing');
|
||||
assert.match(init[1], /event\.key === 'ArrowRight'/);
|
||||
assert.match(init[1], /event\.key === 'ArrowLeft'/);
|
||||
assert.match(init[1], /event\.key === 'Home'/);
|
||||
assert.match(init[1], /event\.key === 'End'/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
const initSource = _initGlobalContextPanel.toString();
|
||||
assert.match(initSource, /event\.key === 'ArrowRight'/);
|
||||
assert.match(initSource, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(initSource, /event\.key === 'Home'/);
|
||||
assert.match(initSource, /event\.key === 'End'/);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(initSource, /contextTabs\[nextIndex\]\.click\(\)/);
|
||||
});
|
||||
|
||||
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
|
||||
@@ -132,22 +131,10 @@ test('Context tabs draw a visible keyboard-focus outline including active tabs',
|
||||
});
|
||||
|
||||
test('Context async action buttons remain focused while busy', () => {
|
||||
const init = ui.slice(
|
||||
ui.indexOf('_initGlobalContextPanel() {'),
|
||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||
);
|
||||
const radio = ui.slice(
|
||||
ui.indexOf('const toggleRadio = async (trigger) => {'),
|
||||
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
|
||||
);
|
||||
const radioSync = ui.slice(
|
||||
ui.indexOf('_renderRadioState(state) {'),
|
||||
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
|
||||
);
|
||||
const clear = ui.slice(
|
||||
ui.indexOf('clearSelectedLayers() {'),
|
||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||
);
|
||||
const init = _initGlobalContextPanel.toString();
|
||||
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||
const clear = clearSelectedLayers.toString();
|
||||
|
||||
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
|
||||
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
|
||||
@@ -157,5 +144,8 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
|
||||
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
|
||||
assert.match(clear, /this\.setClearBusy\(true\)/);
|
||||
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
|
||||
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
|
||||
assert.doesNotMatch(control, /button\.disabled\s*=/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -5,8 +6,8 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
||||
|
||||
/*
|
||||
* Required-attribution keep-out pin.
|
||||
@@ -165,7 +166,7 @@ function flattenRules(source) {
|
||||
rules.push({
|
||||
order: rules.length,
|
||||
media: [...mediaStack],
|
||||
parts: splitTopLevel(head, ',').map((part) => part.trim()).filter(Boolean),
|
||||
parts: splitTopLevel(head, ',').map((part) => part.trim().replace(/\(\s+/g, '(').replace(/\s+\)/g, ')')).filter(Boolean),
|
||||
decls,
|
||||
});
|
||||
index = close + 1;
|
||||
@@ -436,20 +437,21 @@ test('custom properties inside modelled offsets are provably non-negative', () =
|
||||
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
||||
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||
}
|
||||
const start = ui.indexOf('_updateCommandDockTrayStack() {');
|
||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
||||
assert.ok(start > 0, '_updateCommandDockTrayStack is missing');
|
||||
const writer = ui.slice(start, start + 1800);
|
||||
const writer = layout.slice(start, layout.indexOf(' _scheduleAdaptivePanelLayout(', start));
|
||||
// Every value traces back to a rect height, floored at 0 and rounded up.
|
||||
assert.match(writer, /const locationHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
|
||||
assert.match(writer, /const presetsHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
|
||||
assert.match(writer, /const lowerPinnedHeight = [\s\S]{0,160}?getBoundingClientRect\(\)\.height \|\| 0;/);
|
||||
assert.match(writer, /const locationHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const presetsHeight =\s*[\s\S]{0,180}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const lowerPinnedHeight =\s*[\s\S]{0,220}?getBoundingClientRect\(\)\.height\s*\|\| 0;/);
|
||||
assert.match(writer, /const locationHeightPx = Math\.ceil\(locationHeight\);/);
|
||||
assert.match(writer, /const presetsHeightPx = Math\.ceil\(presetsHeight\);/);
|
||||
assert.match(writer, /'--dock-location-pinned-height', `\$\{locationHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-presets-pinned-height', `\$\{presetsHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-lower-pinned-height', `\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
|
||||
assert.match(writer, /'--dock-location-pinned-height',\s*`\$\{locationHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-presets-pinned-height',\s*`\$\{presetsHeightPx\}px`/);
|
||||
assert.match(writer, /'--dock-lower-pinned-height',\s*`\$\{Math\.ceil\(lowerPinnedHeight\)\}px`/);
|
||||
assert.match(writer, /'--dock-pinned-stack-height', stackHeight/);
|
||||
assert.match(writer, /const stackHeight = pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
|
||||
assert.match(writer, /const stackHeight =\s*pinnedCount > 1[\s\S]{0,160}?`calc\(\$\{locationHeightPx\}px \+ \$\{presetsHeightPx\}px \+ 1\.2rem\)`/);
|
||||
});
|
||||
|
||||
test('the inputs behind the measured constants are unchanged', () => {
|
||||
|
||||
+67
-2106
File diff suppressed because it is too large.
Load diff
@@ -1641,3 +1641,28 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
assert.equal(nameless.id, '366999124');
|
||||
assert.equal(nameless.mmsi, '366999124');
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createAisStreamSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
label: 'Test vessel source',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].reference, 'opaque:test-reference');
|
||||
assert.ok(requests[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(aisLiveVesselsLayer.source, 'Test vessel source');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createAisStreamSource());
|
||||
}
|
||||
});
|
||||
+22
-1674
File diff suppressed because it is too large.
Load diff
+87
-4823
File diff suppressed because it is too large.
Load diff
+18
-13
@@ -14,6 +14,11 @@
|
||||
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||||
// plain node:test.
|
||||
import { test } from 'node:test';
|
||||
// Preserve structural pins across component qualification and formatter wrapping.
|
||||
function componentFunctionSource(fn) {
|
||||
return fn.toString().replace(/\blayerState\./g, '').replace(/\bparts\.\w+\./g, '')
|
||||
.replace(/,\s*(?=\))/g, '').replace(/\s+/g, ' ').replace(/\(\s+/g, '(');
|
||||
}
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -67,8 +72,8 @@ import {
|
||||
activateCctvCameraFromWorldClick,
|
||||
} from '../cctvFocusRequest.js';
|
||||
|
||||
const UI_SOURCE = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui.js'),
|
||||
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'cctvPresentation.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -208,7 +213,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
|
||||
});
|
||||
|
||||
assert.equal(insertCount, 0, 'building a 100-camera catalog must insert zero coverage entities');
|
||||
assert.doesNotMatch(cctvLayer.init.toString(), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
|
||||
assert.doesNotMatch(componentFunctionSource(cctvLayer.init), /buildCoverageEntities|ensure(?:Active|Visible)CoverageEntities/);
|
||||
|
||||
const activated = materializeCctvActiveCoverageEntities(records[42], build);
|
||||
assert.equal(activated.length, 5);
|
||||
@@ -224,7 +229,7 @@ test('lazy coverage inserts nothing at catalog init, then materializes only elig
|
||||
assert.equal(coverageOn.length, 14 * 5);
|
||||
assert.equal(insertCount, 15 * 5, 'COVERAGE ON builds only remaining visible/eligible sets');
|
||||
assert.equal(records.filter((record) => record.coverageEntities.length > 0).length, 15);
|
||||
assert.match(refreshCoverageStyles.toString(), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
|
||||
assert.match(componentFunctionSource(refreshCoverageStyles), /ensureVisibleCoverageEntities\(_records, coverageVisible\)/);
|
||||
assert.equal(
|
||||
materializeCctvVisibleCoverageEntities(records, visibleIds, build).length,
|
||||
0,
|
||||
@@ -614,7 +619,7 @@ test('CCTV disable→enable defers the active-camera re-probe until its next act
|
||||
assert.equal(record.activationDone, false);
|
||||
assert.equal(cctvRecordNeedsActivation('active', 'active', record), true);
|
||||
assert.doesNotMatch(
|
||||
cctvLayer.enable.toString(),
|
||||
componentFunctionSource(cctvLayer.enable),
|
||||
/setActiveCamera|runActivationObstructionProbe|pickFromRay/,
|
||||
'enable must restore nominal visuals without entering the activation probe path',
|
||||
);
|
||||
@@ -724,11 +729,11 @@ test('CCTV repeated in-world clicks dispatch focus only for the one real activat
|
||||
|
||||
assert.deepEqual(activated, ['atx-cam-3', 'atx-cam-3', 'atx-cam-3']);
|
||||
assert.deepEqual(requests, [{ cameraId: 'atx-cam-3' }]);
|
||||
assert.match(cctvLayer.init.toString(), /bindCctvWorldClickGesture\(_clickHandler/);
|
||||
assert.match(cctvLayer.init.toString(), /_cctvOverlayHost\.hitTest/);
|
||||
assert.match(cctvLayer.init.toString(), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
|
||||
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
|
||||
assert.match(cctvLayer.init.toString(), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /bindCctvWorldClickGesture\(_clickHandler/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /_cctvOverlayHost\.hitTest/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /sourceId: CCTV_OVERLAY_SOURCE_ID/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cameraId, setActiveCamera\)/);
|
||||
assert.match(componentFunctionSource(cctvLayer.init), /activateCctvCameraFromWorldClick\(cardId, setActiveCamera\)/);
|
||||
});
|
||||
|
||||
// ─── Empty-space deselection and stable null-active state ──────────────────
|
||||
@@ -795,7 +800,7 @@ test('CCTV empty-click gate excludes every identified scene object, ADJUST, and
|
||||
activeCameraId: 'cam-1',
|
||||
}), true, 'an ID-less surface pick remains true empty space');
|
||||
|
||||
const initSource = cctvLayer.init.toString();
|
||||
const initSource = componentFunctionSource(cctvLayer.init);
|
||||
assert.ok(
|
||||
initSource.indexOf('if (pickedId !== null) return')
|
||||
< initSource.indexOf('_cctvOverlayHost.hitTest'),
|
||||
@@ -931,10 +936,10 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
|
||||
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
|
||||
|
||||
const renderer = UI_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n \}\n/);
|
||||
const renderer = CCTV_PRESENTATION_SOURCE.match(/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/);
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(renderer[0], /else if \(!activeId\)[\s\S]*?selectedIndex = -1/);
|
||||
assert.match(renderer[0], /_cctvFocusBtn\.disabled = !enabled \|\| cameras\.length === 0 \|\| !activeId/);
|
||||
assert.match(renderer[0], /_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
_setCctvOverlayHostForTest();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -14,9 +16,8 @@ import militaryFlightsLayer, {
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
|
||||
const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readLayerSource(new URL('./flights.js', import.meta.url));
|
||||
const MILITARY_SOURCE = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
@@ -84,27 +85,35 @@ function candidateIds(layer) {
|
||||
}
|
||||
|
||||
test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => {
|
||||
const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/
|
||||
.exec(UI_SOURCE)?.[0];
|
||||
assert.ok(dispatcher, 'Cockpit event dispatcher is defined');
|
||||
assert.match(dispatcher, /info\?\.icao24/);
|
||||
assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/);
|
||||
assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/);
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/,
|
||||
'entry and in-Cockpit handoff publish the active subject');
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/,
|
||||
const previousWindow = globalThis.window;
|
||||
const details = [];
|
||||
globalThis.window = { dispatchEvent: event => details.push(event.detail) };
|
||||
try {
|
||||
dispatchCockpitModeChanged(true, { icao24: ' ABC123 ', layerId: 'military' });
|
||||
dispatchCockpitModeChanged(true, { icao24: ' DEF456 ', layerId: 'other' });
|
||||
dispatchCockpitModeChanged(false);
|
||||
assert.deepEqual(details, [
|
||||
{ active: true, subjectId: 'abc123', layerId: 'military' },
|
||||
{ active: true, subjectId: 'def456', layerId: null },
|
||||
{ active: false, subjectId: null, layerId: null },
|
||||
]);
|
||||
} finally { globalThis.window = previousWindow; }
|
||||
for (const action of [enter, _adoptTrackedEntity]) {
|
||||
assert.match(action.toString(), /this\.dispatchCockpitModeChanged\(true, info\);/,
|
||||
'entry and in-Cockpit handoff each publish the active subject');
|
||||
}
|
||||
assert.match(exit.toString(), /this\.dispatchCockpitModeChanged\(false\);/,
|
||||
'exit clears the active subject');
|
||||
|
||||
for (const [name, source] of [
|
||||
['commercial', FLIGHTS_SOURCE],
|
||||
['military', MILITARY_SOURCE],
|
||||
]) {
|
||||
const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/
|
||||
const consumer = /function\s*(?:parts\.\w+\.)?_applyCockpitState\(\s*detail\s*=\s*\{\},?\s*\)\s*\{[\s\S]*?\n {0,2}\}/
|
||||
.exec(source)?.[0];
|
||||
assert.ok(consumer, `${name} Cockpit consumer is defined`);
|
||||
assert.match(consumer, /detail\?\.subjectId/);
|
||||
assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(consumer, /\.trim\(\s*,?\s*\)\s*\.toLowerCase\(\s*,?\s*\)/);
|
||||
assert.doesNotMatch(consumer, /layerId/,
|
||||
`${name} must also suppress a duplicate subject originating in the sibling AIR feed`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -25,7 +26,7 @@ function numericConstant(source, name) {
|
||||
}
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
const source = readFileSync(layer.path, 'utf8');
|
||||
const source = readLayerSource(layer.path);
|
||||
|
||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
||||
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
|
||||
@@ -37,11 +38,11 @@ for (const layer of LAYERS) {
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
|
||||
const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_modelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
|
||||
assert.doesNotMatch(regime, /!(?:flightState\.)?_models3dEnabled\s*&&\s*!(?:flightState\.)?_cockpitContactMode/,
|
||||
'Cockpit must not bypass the owner-visible Display toggle');
|
||||
});
|
||||
|
||||
@@ -51,13 +52,13 @@ for (const layer of LAYERS) {
|
||||
// owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so
|
||||
// it no longer routes through the toggle-gated `_modelRegimeActive` — the
|
||||
// suppression is now an explicit early return.
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, '_trackedModelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/,
|
||||
assert.match(regime, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|[\s\S]*?return\s*false;/,
|
||||
'_trackedModelRegimeActive excludes cockpit');
|
||||
const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0];
|
||||
const tracked = /^([ \t]*)function _updateTrackedModel\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(tracked, '_updateTrackedModel is defined');
|
||||
assert.match(tracked, /_trackedModelRegimeActive\(\)/,
|
||||
assert.match(tracked, /(?:parts\.\w+\.)?_trackedModelRegimeActive\(\s*,?\s*\)/,
|
||||
'the tracked-model driver uses the cockpit-aware predicate');
|
||||
});
|
||||
|
||||
@@ -68,15 +69,15 @@ for (const layer of LAYERS) {
|
||||
assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000);
|
||||
assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60);
|
||||
|
||||
const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/);
|
||||
const add = /^([ \t]*)function _modelAddDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
const keep = /^([ \t]*)function _modelKeepDistM\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(add, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_ADD_M\s*:\s*MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /(?:flightState\.)?_models3dMode\s*===\s*'all'\s*\?\s*MODEL_ALL_KEEP_M\s*:\s*MODEL_PROX_KEEP_M/);
|
||||
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
|
||||
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
|
||||
|
||||
const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/,
|
||||
const cap = /^([ \t]*)function _modelCap\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(\s*COCKPIT_MODEL_MAX/,
|
||||
'Cockpit keeps its 60-model performance ceiling');
|
||||
});
|
||||
|
||||
@@ -88,19 +89,19 @@ for (const layer of LAYERS) {
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/,
|
||||
assert.match(source, /bb\.image\s*=\s*aircraftIcon\(\s*_iconKind\(\s*icao24,\s*meta\?\.klass,?\s*\)(,\s*bb\._gevIconLarge\s*\?\s*TRACKED_ICON_PX\s*:\s*undefined)?,?\s*\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /bb\.rotation = 0;/,
|
||||
assert.match(source, /bb\.rotation\s*=\s*0;/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/,
|
||||
assert.match(source, /\(\s*!(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isCockpitNear,?\s*\)\s*&&\s*\(\s*doRotations\s*\|\|\s*revealed,?\s*\)/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/,
|
||||
assert.match(source, /if\s*\(\s*bb\.show,?\s*\)\s*bb\.show\s*=\s*false;\s*\/\/\s*hand\s*off\s*ONLY\s*once\s*the\s*model\s*renders/,
|
||||
'the gap-proof billboard-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/);
|
||||
assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/);
|
||||
const setMode = /^([ \t]*)function _setCockpitContactMode\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(setMode, /else\s*(?:flightState\.)?_cockpitNearContacts\s*=\s*new\s*Set\(\s*,?\s*\);/);
|
||||
assert.match(setMode, /for\s*\(\s*const\s*\[icao24,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)\s*(?:parts\.\w+\.)?_applyFleetBillboardPresentation\(\s*icao24,\s*bb,?\s*\);/);
|
||||
});
|
||||
}
|
||||
@@ -671,7 +671,8 @@ test('pathological detection paint holds alternate frames without freezing share
|
||||
|
||||
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8')
|
||||
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
|
||||
@@ -694,7 +695,7 @@ test('detection cannot resurrect a private canvas, listener, matrix, resize, cle
|
||||
// three times" — same guarantee, and the copies can no longer drift.
|
||||
assert.match(
|
||||
uiSource,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{\s*mode: 'dense',\s*densityPct: 75,?\s*\}\);/,
|
||||
'the tactical detection default is still Dense @ 75%',
|
||||
);
|
||||
assert.equal(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/detectionRenderDemand.test.mjs
|
||||
//
|
||||
// Detection must not hold the render loop open — and must not drop work on the
|
||||
@@ -427,8 +428,8 @@ test('the render-governor gate covers the parked case, with teeth on the painter
|
||||
// promptness goes with them, silently. This test is where that shows up.
|
||||
test('aircraft brackets stay prompt because the aircraft layers hold the render loop', async () => {
|
||||
for (const file of ['./flights.js', './militaryFlights.js']) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
const enable = /\n enable\([\s\S]*?\n \},/.exec(source)?.[0];
|
||||
const source = readLayerSource(new URL(file, import.meta.url));
|
||||
const enable = /\n([ \t]*)enable\([\s\S]*?\n\1\},/.exec(source)?.[0];
|
||||
assert.ok(enable, `${file}: enable() is still identifiable`);
|
||||
assert.match(
|
||||
enable,
|
||||
|
||||
+16
-336
@@ -1,342 +1,22 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createEarthquakesLayer as createLayer,
|
||||
createUsgsEarthquakeSource,
|
||||
} from '../layers/earthquakes/index.js';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* USGS earthquake discs — last 24 hours, M2.5+.
|
||||
*
|
||||
* Ellipse axes are STATIC (plain numbers), redefined only when a poll brings
|
||||
* new data. They must never become a `CallbackProperty` again: every entity
|
||||
* here is a `CLAMP_TO_GROUND` ellipse, and a per-frame axis re-tessellates its
|
||||
* ground primitive on EVERY frame. Measured on the shipped 58-event feed
|
||||
* (2026-08-20 QA hunt, parked camera over SF at 40 km):
|
||||
*
|
||||
* 58 discs, callback axes → 32.4 ms/frame, 30 fps
|
||||
* 58 discs, static axes → 1.4 ms/frame, 60 fps
|
||||
*
|
||||
* The former ±15 % radius "pulse" was DROPPED to buy that back: an
|
||||
* imperceptible breathing wobble on a translucent ground disc is not worth
|
||||
* rebuilding 58 ground primitives 60 times a second. With no per-frame
|
||||
* animator left, the layer also no longer holds the render governor
|
||||
* continuous — the manager's `layer-tick` / `layer-visibility` requests
|
||||
* already cover every discrete mutation this layer makes.
|
||||
*/
|
||||
|
||||
const API_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
|
||||
|
||||
export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
|
||||
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
|
||||
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
|
||||
|
||||
const DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
/**
|
||||
* Color by depth:
|
||||
* - Shallow (<70km): Red
|
||||
* - Intermediate (70-300km): Orange
|
||||
* - Deep (>300km): Yellow
|
||||
*/
|
||||
function depthColor(depthKm) {
|
||||
if (depthKm < 70) return Cesium.Color.RED;
|
||||
if (depthKm < 300) return Cesium.Color.ORANGE;
|
||||
return Cesium.Color.YELLOW;
|
||||
export * from '../layers/earthquakes/index.js';
|
||||
/** Wire the standalone source and application overlay owner. */
|
||||
export function createEarthquakesLayer({
|
||||
source = createUsgsEarthquakeSource(),
|
||||
overlayHost = {
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
},
|
||||
} = {}) {
|
||||
return createLayer({ source, overlayHost });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the source-owned presentation for one ambient magnitude label.
|
||||
* Magnitude formatting deliberately remains here instead of moving into the
|
||||
* shared renderer.
|
||||
* @param {object} input
|
||||
* @param {string} input.id Stable USGS or deterministic fallback id.
|
||||
* @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse.
|
||||
* @param {number} input.magnitude USGS magnitude.
|
||||
* @param {string} input.accent Source-owned depth-band color.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createEarthquakeOverlayEntry({ id, position, magnitude, accent }) {
|
||||
const mag = Number(magnitude);
|
||||
return {
|
||||
id: String(id),
|
||||
position,
|
||||
variant: 'label',
|
||||
title: `M${mag.toFixed(1)}`,
|
||||
accent,
|
||||
priority: Math.round(mag * 1000),
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the largest events, with stable identity as the tie-break. */
|
||||
export function selectEarthquakeOverlayCohort(
|
||||
entries,
|
||||
limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
) {
|
||||
const cap = Math.max(0, Math.min(
|
||||
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
Math.floor(Number(limit) || 0),
|
||||
));
|
||||
if (!Array.isArray(entries) || cap === 0) return [];
|
||||
return entries.slice().sort((a, b) => (
|
||||
b.priority - a.priority || String(a.id).localeCompare(String(b.id))
|
||||
)).slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one earthquake's raw plain values to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. Falls back to an index-based id
|
||||
* when the USGS event id is absent.
|
||||
* @param {Object|null|undefined} raw - Plain values pulled off the entity:
|
||||
* {id, mag, place, time, depth, lat, lon}.
|
||||
* @param {number} [index=0] - Position in the snapshot (fallback id only).
|
||||
* @returns {{id: string, magnitude: number|null, depthKm: number|null,
|
||||
* lat: number|null, lon: number|null, timeMs: number|null, place: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw, index = 0) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
|
||||
return {
|
||||
id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`,
|
||||
magnitude: num(raw?.mag),
|
||||
depthKm: num(raw?.depth),
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
timeMs: num(raw?.time), // USGS epoch ms
|
||||
place: text(raw?.place),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a complete feed before replacing the last good earthquake snapshot. */
|
||||
export function normalizeEarthquakeSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
const properties = feature?.properties;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties
|
||||
|| typeof properties !== 'object' || Array.isArray(properties)
|
||||
|| (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null;
|
||||
const [lon, lat, depthKm] = coordinates;
|
||||
const mag = properties.mag;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180
|
||||
|| !Number.isFinite(lat) || Math.abs(lat) > 90
|
||||
|| (depthKm != null && !Number.isFinite(depthKm))
|
||||
|| (mag != null && (!Number.isFinite(mag) || mag > 10))) return null;
|
||||
// A missing magnitude cannot establish that this event meets M2.5+.
|
||||
if (mag == null || mag < 2.5) continue;
|
||||
const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id);
|
||||
if (ids.has(stableId)) return null;
|
||||
ids.add(stableId);
|
||||
rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null,
|
||||
mag, place: typeof properties.place === 'string' ? properties.place : null,
|
||||
time: Number.isFinite(properties.time) ? properties.time : null });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
|
||||
const layer = {
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes (24h)',
|
||||
icon: '🌋',
|
||||
source: 'USGS',
|
||||
updateInterval: 60000,
|
||||
|
||||
init(viewer) {
|
||||
_dataSource = new Cesium.CustomDataSource('earthquakes');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_enabled = false;
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
console.log('[Data:Earthquakes] Initialized');
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
_enabled = true;
|
||||
// No continuous-render hold: the discs are static geometry now, so the
|
||||
// layer has no per-frame animator to keep the render loop alive for.
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
async update(viewer) {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
_lastError = `USGS HTTP ${response.status}`;
|
||||
console.warn(`[Data:Earthquakes] API returned ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const geojson = await response.json();
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
if (!rows) {
|
||||
_lastError = 'Malformed USGS response';
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextEntities = [];
|
||||
let count = 0;
|
||||
const overlayEntries = [];
|
||||
|
||||
for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) {
|
||||
count++;
|
||||
const baseRadius = Math.pow(2, mag) * 1000;
|
||||
const color = depthColor(depthKm || 0);
|
||||
const isSignificant = mag >= 5.0;
|
||||
const fillAlpha = isSignificant ? 0.4 : 0.3;
|
||||
const outlineAlpha = isSignificant ? 1.0 : 0.8;
|
||||
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
nextEntities.push(new Cesium.Entity({
|
||||
id: `earthquake:${stableId}`,
|
||||
position,
|
||||
ellipse: {
|
||||
// Static axes — see the module header. A CallbackProperty here
|
||||
// re-tessellates the clamped ground geometry every frame.
|
||||
semiMajorAxis: baseRadius,
|
||||
semiMinorAxis: baseRadius,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(fillAlpha)
|
||||
),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(outlineAlpha),
|
||||
outlineWidth: isSignificant ? 3 : 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
properties: {
|
||||
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
|
||||
usgsId,
|
||||
mag,
|
||||
place,
|
||||
time,
|
||||
depth: depthKm,
|
||||
},
|
||||
}));
|
||||
overlayEntries.push(createEarthquakeOverlayEntry({
|
||||
id: String(stableId),
|
||||
position,
|
||||
magnitude: mag,
|
||||
accent: color.toCssColorString(),
|
||||
}));
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
if (_enabled) {
|
||||
overlayHost.setEntries(
|
||||
EARTHQUAKE_OVERLAY_SOURCE_ID,
|
||||
selectEarthquakeOverlayCohort(overlayEntries),
|
||||
{
|
||||
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_count = count;
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`);
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[Data:Earthquakes] Fetch error:', e);
|
||||
_lastError = 'USGS network error';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
_enabled = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot the layer's in-memory earthquake records as plain JSON-safe
|
||||
* objects for the analyst query engine. On-demand only (called at most
|
||||
* once per spoken query) — zero per-frame cost, no listeners, no caching.
|
||||
* Returns [] while the layer is disabled or empty.
|
||||
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const entities = _dataSource.entities.values;
|
||||
if (!entities.length) return [];
|
||||
const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const result = [];
|
||||
for (const entity of entities) {
|
||||
if (result.length >= limit) break;
|
||||
const cartesian = entity.position ? entity.position.getValue(now) : null;
|
||||
const carto = cartesian ? Cesium.Cartographic.fromCartesian(cartesian) : null;
|
||||
const p = entity.properties;
|
||||
result.push(mapAnalystRecord({
|
||||
id: p?.usgsId?.getValue(now) ?? null,
|
||||
mag: p?.mag?.getValue(now),
|
||||
place: p?.place?.getValue(now),
|
||||
time: p?.time?.getValue(now),
|
||||
depth: p?.depth?.getValue(now),
|
||||
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
|
||||
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
|
||||
}, result.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
const earthquakesLayer = createEarthquakesLayer();
|
||||
|
||||
export default earthquakesLayer;
|
||||
export default createEarthquakesLayer();
|
||||
@@ -294,7 +294,8 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
|
||||
});
|
||||
|
||||
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
|
||||
const source = readFileSync(new URL('./earthquakes.js', import.meta.url), 'utf8');
|
||||
const source = ['index', 'model', 'source'].map(name =>
|
||||
readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/new Cesium\.CallbackProperty/,
|
||||
|
||||
+9
-87
@@ -1,87 +1,9 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (owner field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
// The fix rides the height-datum ship's machinery end to end: anchors read
|
||||
// the SAME shared ground floor every other ground-adjacent consumer uses
|
||||
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
|
||||
// cell), and cold cells warm through the same batched, chunked, cached
|
||||
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
|
||||
// fires are static, and floor cells latch warm for the session, so each
|
||||
// rendered detection costs at most one DEM lookup EVER — and detections
|
||||
// sharing a ~111 m coarse cell share that lookup.
|
||||
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
|
||||
|
||||
/** @constant {number} Metres above the resolved floor for a fire anchor.
|
||||
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
|
||||
* a few metres of lift biases "slightly above the visible surface" (owner
|
||||
* principle) without a visible hover on the screen-sized sprite. */
|
||||
export const FIRE_ANCHOR_LIFT_M = 5;
|
||||
|
||||
/**
|
||||
* Synchronous anchor height for one detection: shared ground floor + lift
|
||||
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
|
||||
* exactly as before until their floor lands). Warm-cache read only; never
|
||||
* triggers network or sampling.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number} Ellipsoidal anchor height in metres.
|
||||
*/
|
||||
export function fireAnchorHeight(lat, lon) {
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
|
||||
}
|
||||
|
||||
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
|
||||
* sequentially so overlapping renders can't stack concurrent requests on
|
||||
* the single dev-server proxy (same courtesy as terrainHeights' sequential
|
||||
* chunking). Each queued batch re-filters against the warm cache when it
|
||||
* actually runs, so cells resolved by an earlier batch are never refetched. */
|
||||
let _chain = null;
|
||||
|
||||
/**
|
||||
* Batched warm of the ground-floor cells behind a rendered detection set.
|
||||
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
|
||||
* one requested point actually gained a warm floor — a failed resolve (proxy
|
||||
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
|
||||
* `false`, so a render → warm → re-render chain terminates instead of
|
||||
* looping; the next natural rebuild retries and the cache self-heals.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
|
||||
* @returns {Promise<boolean>} Whether any requested point warmed.
|
||||
*/
|
||||
export function warmFireAnchorFloors(points) {
|
||||
const cold = collectCold(points);
|
||||
if (!cold.length) return Promise.resolve(false);
|
||||
const prev = _chain;
|
||||
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
|
||||
_chain = run.then(() => true, () => false);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
|
||||
function collectCold(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
const cold = [];
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
|
||||
cold.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
return cold;
|
||||
}
|
||||
|
||||
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
|
||||
async function resolveBatch(points) {
|
||||
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
|
||||
if (!cold.length) return false;
|
||||
try {
|
||||
await resolveGroundFloorCells(cold);
|
||||
} catch { /* resolver is best-effort and never throws; belt and braces */ }
|
||||
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
|
||||
}
|
||||
|
||||
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
|
||||
export function _resetFireAnchorsForTest() {
|
||||
_chain = null;
|
||||
}
|
||||
import { createFireAnchors } from '../layers/firms/anchors.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
export { FIRE_ANCHOR_LIFT_M } from '../layers/firms/anchors.js';
|
||||
const anchors = createFireAnchors(groundFloor);
|
||||
export const {
|
||||
fireAnchorHeight,
|
||||
warmFireAnchorFloors,
|
||||
_resetFireAnchorsForTest,
|
||||
} = anchors;
|
||||
+45
-1602
File diff suppressed because it is too large.
Load diff
@@ -9,11 +9,11 @@ import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from
|
||||
import { fireDetectionKey } from './firmsLabels.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import fs from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LAYER_SOURCE = fs.readFileSync(
|
||||
const LAYER_SOURCE = readLayerSource(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
|
||||
'utf8',
|
||||
);
|
||||
@@ -40,7 +40,7 @@ function makeFire(overrides = {}) {
|
||||
*/
|
||||
function harness({
|
||||
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
|
||||
withDataSource = false,
|
||||
withDataSource = false, feed = null,
|
||||
} = {}) {
|
||||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||||
const priorWindow = globalThis.window;
|
||||
@@ -58,6 +58,7 @@ function harness({
|
||||
const published = [];
|
||||
let handler = null;
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
...(feed ? { feed } : {}),
|
||||
id: 'firms-test',
|
||||
name: 'Fires',
|
||||
overlayHost: {
|
||||
@@ -349,3 +350,21 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
|
||||
let resolve, signal;
|
||||
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
|
||||
signal = options.signal; return new Promise(done => { resolve = done; });
|
||||
} } });
|
||||
try {
|
||||
const before = h.layer.getStats();
|
||||
const pending = h.layer.update();
|
||||
h.layer.disable();
|
||||
assert.equal(signal.aborted, true);
|
||||
resolve({ fires: [], fetchedAt: Date.now() });
|
||||
await pending;
|
||||
assert.equal(h.layer.getStats().count, before.count);
|
||||
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
|
||||
assert.equal(h.layer.getStats().loading, false);
|
||||
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
|
||||
});
|
||||
@@ -4,6 +4,6 @@
|
||||
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
|
||||
z12 x935 y1686, captured 2026-07-16 from
|
||||
`api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf`
|
||||
(22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT
|
||||
decoding offline — it is a point-in-time congestion snapshot, not a bundled
|
||||
data layer, and is never served to the app. © TomTom.
|
||||
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
|
||||
browser mode — it is a point-in-time congestion snapshot, not a bundled
|
||||
data layer, and is never loaded by ordinary application startup. © TomTom.
|
||||
+73
-5336
File diff suppressed because it is too large.
Load diff
+6
-161
@@ -1,161 +1,6 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tilesForBounds } from './tomtomTiles.js';
|
||||
|
||||
/**
|
||||
* @file TomTom traffic-flow vector-tile client: fetch + MVT decode.
|
||||
*
|
||||
* Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy
|
||||
* (the TomTom key never reaches the browser) and decodes the Mapbox Vector
|
||||
* Tile layer "Traffic flow" into plain lon/lat polylines with congestion
|
||||
* attributes. Consumed by the traffic layer's live mode
|
||||
* (`src/data/traffic.js` → `src/data/flowMatch.js`).
|
||||
*
|
||||
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
|
||||
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
|
||||
* ratio (1 = free flow). Features with a missing/non-finite `traffic_level`
|
||||
* are skipped unless `road_closure` is true (closures decode with level 0).
|
||||
*
|
||||
* Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and
|
||||
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
|
||||
* binary cache.
|
||||
*
|
||||
* @module data/flowTiles
|
||||
*/
|
||||
|
||||
export { tilesForBounds };
|
||||
|
||||
/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */
|
||||
const FLOW_LAYER_NAME = 'Traffic flow';
|
||||
/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */
|
||||
const DECODE_CACHE_TTL_MS = 120_000;
|
||||
/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */
|
||||
const DECODE_CACHE_MAX_ENTRIES = 64;
|
||||
|
||||
/**
|
||||
* Decoded-tile cache keyed by "z/x/y".
|
||||
* @type {Map<string, {at:number, segments:Array}>}
|
||||
*/
|
||||
const _decodeCache = new Map();
|
||||
/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */
|
||||
let _tilesFetched = 0;
|
||||
|
||||
/**
|
||||
* Decode one TomTom flow tile (MVT protobuf) into flow segments.
|
||||
*
|
||||
* @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes.
|
||||
* @param {number} z - Tile zoom (for tile-local → lon/lat projection).
|
||||
* @param {number} x - Tile column.
|
||||
* @param {number} y - Tile row.
|
||||
* @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>}
|
||||
* Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers
|
||||
* or tiles without a "Traffic flow" layer (defensive — a corrupt tile must
|
||||
* not kill the traffic layer).
|
||||
*/
|
||||
export function decodeFlowTile(data, z, x, y) {
|
||||
let layer;
|
||||
try {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
layer = tile.layers[FLOW_LAYER_NAME];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!layer) return [];
|
||||
|
||||
const segments = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
let feature;
|
||||
let geometry;
|
||||
try {
|
||||
feature = layer.feature(i);
|
||||
geometry = feature.toGeoJSON(x, y, z).geometry;
|
||||
} catch {
|
||||
continue; // one malformed feature must not drop the tile
|
||||
}
|
||||
const props = feature.properties || {};
|
||||
const closure = props.road_closure === true || props.road_closure === 'true';
|
||||
const rawLevel = props.traffic_level;
|
||||
const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel);
|
||||
// Skip features we can't color — unless closed (closures render dot-free
|
||||
// regardless of level, so they stay useful without one).
|
||||
if (!hasLevel && !closure) continue;
|
||||
const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0;
|
||||
const roadType = typeof props.road_type === 'string' ? props.road_type : '';
|
||||
|
||||
const lines = geometry.type === 'LineString'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'MultiLineString'
|
||||
? geometry.coordinates
|
||||
: [];
|
||||
for (const coords of lines) {
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
segments.push({ coords, trafficLevel, roadType, closure });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Insert into the decode cache with oldest-entry eviction. */
|
||||
function cacheSet(key, entry) {
|
||||
if (!_decodeCache.has(key) && _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = _decodeCache.keys().next().value;
|
||||
_decodeCache.delete(oldest);
|
||||
}
|
||||
_decodeCache.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + decode all flow tiles covering the given bounds.
|
||||
*
|
||||
* Tiles are fetched from the local proxy in parallel; each decoded tile is
|
||||
* cached in memory for 120 s (keyed z/x/y), so repeat calls for the same
|
||||
* viewport are free. Partial tile failures return the segments that DID
|
||||
* decode (last-good philosophy); the promise rejects only when every tile
|
||||
* failed (e.g. keyless 503, aborted signal, proxy down).
|
||||
*
|
||||
* @param {{south:number, west:number, north:number, east:number}} bounds - Degrees.
|
||||
* @param {Object} [opts]
|
||||
* @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled).
|
||||
* @param {number} [opts.zoom=12] - Flow tile zoom level.
|
||||
* @returns {Promise<Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>>}
|
||||
* Flat array of flow segments across all covering tiles.
|
||||
*/
|
||||
export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
|
||||
const tiles = tilesForBounds(bounds, zoom);
|
||||
if (tiles.length === 0) return [];
|
||||
const now = Date.now();
|
||||
|
||||
const results = await Promise.allSettled(tiles.map(async ({ z, x, y }) => {
|
||||
const key = `${z}/${x}/${y}`;
|
||||
const cached = _decodeCache.get(key);
|
||||
if (cached && now - cached.at < DECODE_CACHE_TTL_MS) return cached.segments;
|
||||
|
||||
_tilesFetched += 1;
|
||||
const res = await fetch(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { signal });
|
||||
if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`);
|
||||
const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y);
|
||||
cacheSet(key, { at: Date.now(), segments });
|
||||
return segments;
|
||||
}));
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
if (fulfilled.length === 0) {
|
||||
throw results[0].reason instanceof Error ? results[0].reason : new Error('flow fetch failed');
|
||||
}
|
||||
return fulfilled.flatMap((r) => r.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session diagnostics for `getStats()` surfaces.
|
||||
* @returns {{tilesFetched:number}} Count of tile requests issued to the proxy
|
||||
* this session (decode-cache hits excluded).
|
||||
*/
|
||||
export function getFlowSessionStats() {
|
||||
return { tilesFetched: _tilesFetched };
|
||||
}
|
||||
|
||||
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
|
||||
export function resetFlowTileCache() {
|
||||
_decodeCache.clear();
|
||||
}
|
||||
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
|
||||
export { tilesForBounds } from './tomtomTiles.js';
|
||||
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
|
||||
const source = createFlowTileSource();
|
||||
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
|
||||
source;
|
||||
@@ -5,14 +5,17 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
};
|
||||
const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
}
|
||||
if (stats.status === 'unavailable') return reason;
|
||||
if (stats.status === 'zoom-in') return 'Zoom in to search mapped installations';
|
||||
if (stats.status === 'zoom-in')
|
||||
return 'Zoom in to search mapped installations';
|
||||
if (stats.stale) return 'Showing cached mapped sites';
|
||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
||||
return 'Mapped sites loaded';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -365,21 +366,21 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
// first-run session actually boots from.
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /^let _models3dEnabled = true;$/m,
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.match(source, /^\s*(?:let |flightState\.)_models3dEnabled = true;$/m,
|
||||
`${name}: the fleet starts armed, matching the codec default`);
|
||||
assert.match(source, /^let _models3dMode = 'proximity';/m,
|
||||
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
|
||||
`${name}: and starts in proximity, matching the codec default`);
|
||||
}
|
||||
const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /^\s*this\._models3dEnabled = true;$/m,
|
||||
const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
|
||||
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
|
||||
assert.match(ui, /this\._models3dMode = 'proximity';/,
|
||||
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||
'ui.js: and believes the mode is proximity');
|
||||
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
|
||||
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\('aria-pressed', String\(this\._models3dEnabled\)\)/,
|
||||
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
|
||||
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only');
|
||||
assert.match(html, /class="pp-slider-row visible" id="models3d-mode-row"/,
|
||||
'index.html: and the Proximity/All row paints open with it');
|
||||
|
||||
@@ -354,6 +354,17 @@ export function createLocalGeoJsonLayer(
|
||||
let _destroyed = false;
|
||||
let _loadPromise = null;
|
||||
let _loadController = null;
|
||||
/**
|
||||
* The parsed bundled dataset, kept across disable/enable so a re-enable
|
||||
* rebuilds entities without refetching. The Cesium entities themselves are
|
||||
* NOT kept: a hidden data source still costs every frame (DataSourceDisplay
|
||||
* walks every visualizer over every entity regardless of `show`), and the
|
||||
* ~11k entities of the three bundled layers hold hundreds of MB, so leaving
|
||||
* them parked after a toggle-off made the whole scene render ~2× slower for
|
||||
* the rest of the session (owner field test 2026-09-13).
|
||||
* @type {Array<object>|null}
|
||||
*/
|
||||
let _cachedFeatures = null;
|
||||
/**
|
||||
* Globe-LOD active set: the record ids allowed to carry a live stem right
|
||||
* now. This bounds geometry refreshes and ground-sample work to the
|
||||
@@ -432,6 +443,27 @@ export function createLocalGeoJsonLayer(
|
||||
host: overlayHost,
|
||||
});
|
||||
|
||||
/**
|
||||
* Take the built entities out of the scene entirely. The parsed features
|
||||
* stay cached, so the next enable() rebuilds without a fetch; what must not
|
||||
* survive a disable is the per-frame visualizer walk and the entity memory.
|
||||
* @param {object} viewer
|
||||
*/
|
||||
const releaseDataSource = (viewer) => {
|
||||
if (!_dataSource) return;
|
||||
const source = _dataSource;
|
||||
_dataSource = null;
|
||||
_stemRecords = [];
|
||||
_stemGeometryDirty = true;
|
||||
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
|
||||
removeEntityContextsForLayer(id);
|
||||
try {
|
||||
viewer?.dataSources?.remove(source, true);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
const disableLayer = (viewer) => {
|
||||
_enabled = false;
|
||||
clearGroundRetryRender();
|
||||
@@ -440,7 +472,7 @@ export function createLocalGeoJsonLayer(
|
||||
_lastLodBudgetLimit = 0;
|
||||
_lodComputed = false;
|
||||
_lastLodProbeMs = Number.NEGATIVE_INFINITY;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
releaseDataSource(viewer);
|
||||
_overlayPublisher.hide();
|
||||
clearSelectedEntityContextForLayer(id);
|
||||
if (viewer?.selectedEntity?.__localLayerId === id) {
|
||||
@@ -529,20 +561,26 @@ export function createLocalGeoJsonLayer(
|
||||
// windows (before vs after the add settles) need different cleanup.
|
||||
let addedToScene = false;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: _loadController.signal,
|
||||
});
|
||||
if (_destroyed) return;
|
||||
// A 404 returns an HTML body that would otherwise die in JSON.parse
|
||||
// one line later, reported as a parse error for a missing file.
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status ?? '?'}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
||||
let features = _cachedFeatures;
|
||||
if (!features) {
|
||||
const response = await fetch(url, {
|
||||
signal: _loadController.signal,
|
||||
});
|
||||
if (_destroyed) return;
|
||||
// A 404 returns an HTML body that would otherwise die in JSON.parse
|
||||
// one line later, reported as a parse error for a missing file.
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status ?? '?'}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
|
||||
const features = lines.map((line) => JSON.parse(line));
|
||||
features = lines.map((line) => JSON.parse(line));
|
||||
_cachedFeatures = features;
|
||||
}
|
||||
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
@@ -991,9 +1029,14 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
|
||||
// Honor a disable() that landed while we were awaiting the fetch/parse:
|
||||
// disable() runs before _dataSource exists, so its show=false is a no-op —
|
||||
// reading _enabled here (rather than forcing true) respects the toggle-off.
|
||||
if (_dataSource) _dataSource.show = _enabled;
|
||||
// disable() runs before _dataSource exists, so its release is a no-op —
|
||||
// release the finished build here instead of parking it hidden in the
|
||||
// scene (the parsed features stay cached for the next enable).
|
||||
if (!_enabled) {
|
||||
releaseDataSource(viewer);
|
||||
return;
|
||||
}
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
viewer.scene.requestRender?.();
|
||||
},
|
||||
|
||||
@@ -1016,6 +1059,7 @@ export function createLocalGeoJsonLayer(
|
||||
}
|
||||
_overlayPublisher.destroy();
|
||||
_dataSource = null;
|
||||
_cachedFeatures = null;
|
||||
_stemRecords = [];
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
|
||||
@@ -54,7 +54,8 @@ test('destroy removes context records and permits a fresh replacement', async t
|
||||
const oldEntity = [...getContextStore().entities.values()][0].entity;
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
env.layer.disable(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records');
|
||||
assert.equal(getContextStore().entities.size, 0, 'disable releases its records with the source');
|
||||
assert.equal(env.sources.size, 0, 'disable removes the built source from the scene');
|
||||
env.layer.destroy(env.viewer);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
assert.equal(env.sources.size, 0);
|
||||
@@ -126,17 +127,41 @@ test('concurrent enable and disable/re-enable share one pending dataset load', a
|
||||
assert.equal([...env.sources][0].show, true);
|
||||
});
|
||||
|
||||
test('disable during loading keeps the completed source hidden until re-enabled', async t => {
|
||||
test('disable during loading releases the completed source; re-enable rebuilds without refetching', async t => {
|
||||
const env = harness(t);
|
||||
const release = deferred();
|
||||
t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
|
||||
const loading = env.layer.enable(env.viewer);
|
||||
env.layer.disable(env.viewer);
|
||||
release.resolve();
|
||||
await loading;
|
||||
assert.equal([...env.sources][0].show, false);
|
||||
assert.equal(env.sources.size, 0, 'a build that finishes after disable() is released, not parked hidden');
|
||||
assert.equal(env.listeners.size, 0);
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
await env.layer.enable(env.viewer);
|
||||
assert.equal([...env.sources][0].show, true);
|
||||
assert.equal(env.sources.size, 1);
|
||||
assert.equal([...env.sources][0].show, true);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'the parsed dataset is cached across the toggle');
|
||||
});
|
||||
|
||||
test('a toggle-off frees the entities and a toggle-on rebuilds them from the cached dataset', async t => {
|
||||
const env = harness(t);
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => response());
|
||||
await env.layer.enable(env.viewer);
|
||||
const first = [...env.sources][0];
|
||||
assert.equal(first.entities.values.length, 1);
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
env.layer.disable(env.viewer);
|
||||
assert.equal(env.sources.size, 0, 'no data source stays registered while the layer is off');
|
||||
assert.equal(getContextStore().entities.size, 0);
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 0);
|
||||
assert.equal(env.layer.getStats().count, 1, 'the row keeps reporting the dataset size');
|
||||
await env.layer.enable(env.viewer);
|
||||
const second = [...env.sources][0];
|
||||
assert.notEqual(second, first, 're-enable builds a fresh source');
|
||||
assert.equal(second.show, true);
|
||||
assert.equal(second.entities.values.length, 1);
|
||||
assert.equal(getContextStore().entities.size, 1);
|
||||
assert.equal(fetchMock.mock.callCount(), 1, 'no second fetch for a bundled dataset');
|
||||
assert.equal(env.layer.getLodDiagnostics().total, 1);
|
||||
});
|
||||
+25
-330
@@ -1,5 +1,6 @@
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { LayerPanel } from '../ui/layers.js';
|
||||
export { layerFeedState } from '../ui/layers.js';
|
||||
import { markDetectionSourcesChanged } from './detection.js';
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
@@ -11,15 +12,6 @@ function cloneLayerParams(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
loading: 'LOADING',
|
||||
degraded: 'DEGRADED',
|
||||
stale: 'STALE',
|
||||
fallback: 'FALLBACK',
|
||||
unavailable: 'UNAVAILABLE',
|
||||
});
|
||||
|
||||
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
|
||||
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
@@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize heterogeneous layer stats into one honest control-chip state.
|
||||
* @param {object|null} stats Layer getStats() result.
|
||||
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
|
||||
*/
|
||||
export function layerFeedState(stats = {}) {
|
||||
const state = stats || {};
|
||||
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
|
||||
const source = `${state.source || ''} ${state.coverage || ''}`;
|
||||
const hasExplicitFallback = typeof state.fallback === 'boolean';
|
||||
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
|
||||
const presentedError = state.error || state.lastError || state.managerRefreshError;
|
||||
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
|
||||
if (
|
||||
(presentedError || state.unavailable === true || state.available === false)
|
||||
&& !hasPriorData
|
||||
&& !GUIDANCE_STATUSES.includes(status)
|
||||
) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (state.loading) return 'loading';
|
||||
// Guidance states ask the user to act (zoom in, run a search) — normal
|
||||
// operation, not feed faults. One honesty carve-out: layers keep their
|
||||
// rendered records through the guidance state, so a genuinely stale cache
|
||||
// still reads STALE; a guidance prompt alone never reads DEGRADED.
|
||||
if (GUIDANCE_STATUSES.includes(status)) {
|
||||
return state.stale ? 'stale' : 'nominal';
|
||||
}
|
||||
if (
|
||||
state.fallback === true
|
||||
|| status === 'fallback'
|
||||
|| state.mode === 'sim'
|
||||
|| /\bfallback\b/i.test(source)
|
||||
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
|
||||
) {
|
||||
return 'fallback';
|
||||
}
|
||||
if (state.stale || status === 'stale') return 'stale';
|
||||
if (
|
||||
state.degraded
|
||||
|| presentedError
|
||||
|| state.unavailable === true
|
||||
|| state.available === false
|
||||
) return 'degraded';
|
||||
return 'nominal';
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLayerManager — Manages registration, toggling, and update loops
|
||||
* for real-time data overlays on the CesiumJS globe.
|
||||
@@ -1894,6 +1839,9 @@ export class DataLayerManager {
|
||||
* Should be called when the viewer is being torn down.
|
||||
*/
|
||||
async destroyAll() {
|
||||
this._layerPanel?.destroy();
|
||||
this._layerPanel = null;
|
||||
this._toggleContainer = null;
|
||||
for (const layerId of [...this.layers.keys()]) {
|
||||
await this.destroyLayer(layerId);
|
||||
}
|
||||
@@ -2019,98 +1967,6 @@ export class DataLayerManager {
|
||||
this._renderToggles();
|
||||
}
|
||||
|
||||
_renderToggles() {
|
||||
if (!this._toggleContainer) return;
|
||||
this._toggleContainer.innerHTML = '';
|
||||
|
||||
for (const layer of this.getAll()) {
|
||||
if (!layer.showInTogglePanel) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'data-toggle-row';
|
||||
row.dataset.layerId = layer.id;
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'data-toggle-top';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'data-toggle-left';
|
||||
left.innerHTML = `<span class="data-icon">${layer.icon}</span><span class="data-name">${layer.name}</span>`;
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'data-toggle-right';
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'data-count';
|
||||
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
|
||||
this._syncToggleButton(toggle, layer);
|
||||
toggle.addEventListener('click', async () => {
|
||||
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
|
||||
// the lifecycle control focusable while it is busy, and enforce the
|
||||
// same single-flight interaction contract through ARIA instead.
|
||||
if (toggle.getAttribute('aria-disabled') === 'true') return;
|
||||
toggle.setAttribute('aria-disabled', 'true');
|
||||
toggle.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
|
||||
} catch (error) {
|
||||
console.warn(`[Data] ${layer.id} toggle error:`, error);
|
||||
} finally {
|
||||
const current = this.getAll().find(({ id }) => id === layer.id);
|
||||
if (current) this._syncToggleButton(toggle, current);
|
||||
}
|
||||
});
|
||||
|
||||
right.appendChild(count);
|
||||
right.appendChild(toggle);
|
||||
topRow.appendChild(left);
|
||||
topRow.appendChild(right);
|
||||
|
||||
const bottomRow = document.createElement('div');
|
||||
bottomRow.className = 'data-toggle-meta';
|
||||
bottomRow.textContent = this._buildMetaText(layer);
|
||||
|
||||
row.appendChild(topRow);
|
||||
row.appendChild(bottomRow);
|
||||
|
||||
// Optional per-layer sub-controls (chips + color legend). The click
|
||||
// listener is delegated and attached once here, so it survives
|
||||
// _refreshTogglePanel — which only rewrites the container's contents.
|
||||
const rowModule = this.layers.get(layer.id)?.module;
|
||||
if (typeof rowModule?.getRowControls === 'function') {
|
||||
// A layer whose controls settle asynchronously (a chunked catalog load
|
||||
// that can also fail) pushes a re-render through this; nothing else
|
||||
// would repaint the row before its next scheduled refresh.
|
||||
rowModule.setRowControlsListener?.(() => this._refreshTogglePanel());
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'data-toggle-controls';
|
||||
controls.addEventListener('click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Re-read the live descriptor rather than trusting the rendered
|
||||
// chip, so a stale row can never apply an inverted toggle.
|
||||
const chip = this._rowControlsFor(layer.id)?.chips
|
||||
?.find((entry) => entry.id === button.dataset.chipId);
|
||||
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
||||
});
|
||||
row.appendChild(controls);
|
||||
this._syncRowControls(controls, layer);
|
||||
}
|
||||
|
||||
this._toggleContainer.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a layer's optional row-control descriptor, tolerating a throw so one
|
||||
* misbehaving layer cannot blank the whole panel. Resolved from the registry
|
||||
* rather than the `getAll()` projection, which deliberately omits `module`.
|
||||
* @param {string} layerId Registered layer id.
|
||||
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
|
||||
*/
|
||||
_rowControlsFor(layerId) {
|
||||
const module = this.layers.get(layerId)?.module;
|
||||
if (typeof module?.getRowControls !== 'function') return null;
|
||||
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a layer's row chips and color legend, and keep the whole block
|
||||
* hidden while the layer is off (or while a dependency owner has surrendered
|
||||
* it) so a quiet row stays quiet.
|
||||
*
|
||||
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
|
||||
* rebuilt: this runs on every panel refresh — including the one the chip's
|
||||
* own click triggers — and replacing the node would drop keyboard focus
|
||||
* mid-interaction. Legend entries hold no focus and no listeners, so they
|
||||
* are replaced freely.
|
||||
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
|
||||
* @param {object} layer Registered layer entry.
|
||||
*/
|
||||
_syncRowControls(container, layer) {
|
||||
if (!container) return;
|
||||
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
||||
const chips = controls?.chips || [];
|
||||
const legend = controls?.legend || [];
|
||||
container.hidden = chips.length === 0 && legend.length === 0;
|
||||
|
||||
for (const node of [...container.children]) {
|
||||
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
|
||||
}
|
||||
|
||||
const stale = new Map();
|
||||
for (const node of [...container.children]) {
|
||||
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
||||
}
|
||||
|
||||
for (const chip of chips) {
|
||||
let button = stale.get(chip.id);
|
||||
stale.delete(chip.id);
|
||||
if (!button) {
|
||||
button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.chipId = chip.id;
|
||||
container.appendChild(button);
|
||||
}
|
||||
const state = chip.state || (chip.active ? 'active' : 'idle');
|
||||
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
|
||||
if (button.textContent !== chip.label) button.textContent = chip.label;
|
||||
button.title = chip.title || '';
|
||||
button.disabled = Boolean(chip.disabled);
|
||||
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
|
||||
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
|
||||
}
|
||||
for (const node of stale.values()) node.remove();
|
||||
|
||||
for (const item of legend) {
|
||||
const entry = document.createElement('span');
|
||||
entry.className = 'data-toggle-legend-item';
|
||||
if (item.blurb) entry.title = item.blurb;
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'data-toggle-legend-swatch';
|
||||
swatch.style.background = item.color;
|
||||
const text = document.createElement('span');
|
||||
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
|
||||
entry.append(swatch, text);
|
||||
container.appendChild(entry);
|
||||
}
|
||||
}
|
||||
|
||||
_refreshTogglePanel() {
|
||||
if (!this._toggleContainer) return;
|
||||
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
||||
// refresh on return. (perf wave 2)
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
this._panelRefreshPendingOnVisible = true;
|
||||
return;
|
||||
}
|
||||
for (const layer of this.getAll()) {
|
||||
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
|
||||
if (!row) continue;
|
||||
|
||||
const btn = row.querySelector('.data-toggle-btn');
|
||||
if (btn) {
|
||||
this._syncToggleButton(btn, layer);
|
||||
}
|
||||
|
||||
const count = row.querySelector('.data-count');
|
||||
if (count) {
|
||||
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
|
||||
}
|
||||
|
||||
const meta = row.querySelector('.data-toggle-meta');
|
||||
if (meta) {
|
||||
meta.textContent = this._buildMetaText(layer);
|
||||
}
|
||||
|
||||
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
|
||||
}
|
||||
}
|
||||
|
||||
_buildMetaText(layer) {
|
||||
const stats = layer.stats || {};
|
||||
const feedState = layerFeedState(stats);
|
||||
const stateLabel = FEED_STATE_LABELS[feedState];
|
||||
const source = stats.source || layer.source;
|
||||
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
return `${lifecycleState.toUpperCase()} · ${source}`;
|
||||
}
|
||||
if (layer.lifecycleUncertain) {
|
||||
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
|
||||
}
|
||||
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
|
||||
if (presentedError) {
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
|
||||
}
|
||||
return `${stateLabel} · ${source} · ${presentedError}`;
|
||||
}
|
||||
// A guidance status carries its prompt in `statusMessage`, not `error`, so
|
||||
// the row still tells the operator what to do without reporting a fault.
|
||||
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
|
||||
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
|
||||
return `${source} · ${stats.statusMessage.trim()}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
|
||||
if (stats.loading) {
|
||||
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: 'loading...';
|
||||
return `${source} · ${loadingLabel}`;
|
||||
}
|
||||
if (feedState === 'fallback') {
|
||||
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
? stats.loadingLabel.trim()
|
||||
: (stats.coverage || ago);
|
||||
return `${stateLabel} · ${source} · ${detail}`;
|
||||
}
|
||||
if (feedState === 'stale') {
|
||||
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
|
||||
? ` · retrying in ${stats.retryInSec}s`
|
||||
: '';
|
||||
return `${stateLabel} · ${source} · ${ago}${retry}`;
|
||||
}
|
||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||
return `${source} · ${stats.loadingLabel.trim()}`;
|
||||
}
|
||||
return `${source} · ${ago}`;
|
||||
}
|
||||
|
||||
_syncToggleButton(button, layer) {
|
||||
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
||||
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
|
||||
const uncertain = Boolean(layer.lifecycleUncertain);
|
||||
button.classList.toggle('active', layer.enabled);
|
||||
button.classList.toggle('transitioning', transitioning);
|
||||
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
||||
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
||||
button.classList.toggle('lifecycle-uncertain', uncertain);
|
||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
|
||||
}
|
||||
button.dataset.feedState = transitioning
|
||||
? layer.lifecycleState
|
||||
: (uncertain ? 'uncertain' : feedState);
|
||||
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
|
||||
// click guard above prevents repeat activation without the focus loss caused
|
||||
// by native `disabled`.
|
||||
button.disabled = false;
|
||||
button.setAttribute('aria-disabled', String(transitioning));
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? layer.lifecycleState.toUpperCase()
|
||||
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
|
||||
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
|
||||
}
|
||||
|
||||
_formatCount(n) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
_timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
_getLayerPanel() {
|
||||
if (!this._layerPanel) this._layerPanel = new LayerPanel({
|
||||
getLayers: () => this.getAll(),
|
||||
isEnabled: (id) => this.isEnabled(id),
|
||||
setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
|
||||
setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
|
||||
getRowControls: (id) => this._rowControlsFor(id),
|
||||
hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
|
||||
subscribeRowControls: (id, listener) => {
|
||||
const module = this.layers.get(id)?.module;
|
||||
module?.setRowControlsListener?.(listener);
|
||||
return () => module?.setRowControlsListener?.(null);
|
||||
},
|
||||
onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
|
||||
});
|
||||
return this._layerPanel;
|
||||
}
|
||||
_renderToggles() { this._getLayerPanel().mount(this._toggleContainer); }
|
||||
_refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); }
|
||||
_buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); }
|
||||
_syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); }
|
||||
}
|
||||
@@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator'
|
||||
},
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
querySelector(selector) {
|
||||
if (selector.startsWith('[data-layer-id="')) {
|
||||
@@ -2756,6 +2757,7 @@ function makeControlElement() {
|
||||
},
|
||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
||||
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
closest(selector) {
|
||||
@@ -3267,3 +3269,36 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
|
||||
assert.match(text, /Zoom in to search mapped installations$/);
|
||||
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
||||
});
|
||||
|
||||
|
||||
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
||||
const manager = new DataLayerManager({});
|
||||
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
||||
let rowListener;
|
||||
layer.module.getRowControls = () => ({ chips: [] });
|
||||
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
|
||||
manager.register(layer.module);
|
||||
try {
|
||||
const first = makeControlElement();
|
||||
manager.buildTogglePanel(first);
|
||||
const oldButton = first.querySelector('.data-toggle-btn');
|
||||
const retainedClick = oldButton.listeners.click;
|
||||
const second = makeControlElement();
|
||||
manager.buildTogglePanel(second);
|
||||
assert.equal(oldButton.listeners.click, undefined);
|
||||
await retainedClick();
|
||||
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
|
||||
const currentButton = second.querySelector('.data-toggle-btn');
|
||||
assert.equal(typeof currentButton.listeners.click, 'function');
|
||||
assert.equal(typeof rowListener, 'function');
|
||||
await manager.destroyAll();
|
||||
assert.equal(currentButton.listeners.click, undefined);
|
||||
assert.equal(rowListener, null);
|
||||
} finally {
|
||||
await manager.destroyAll();
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
+42
-1944
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
} from './militaryAwarenessEngine.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
|
||||
const militaryAwarenessSource = fs.readFileSync(
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
@@ -297,7 +298,7 @@ test('Contacts live repaint restores only stable identity and never retargets an
|
||||
});
|
||||
|
||||
test('Contacts continuation target is the visible explanatory note', () => {
|
||||
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url));
|
||||
assert.match(
|
||||
source,
|
||||
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
|
||||
@@ -1352,12 +1353,12 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
// disable() hides the collection but keeps the records, so a map lookup
|
||||
// alone would report a preserved subject as FRESH from hidden stale data.
|
||||
for (const [name, source, guard] of [
|
||||
['flights', fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
|
||||
['militaryFlights', fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(icao24\) \{\s*\n\s*if \(!_billboardCollection \|\| !_billboardCollection\.show \|\| _billboards\.size === 0\) return null;/],
|
||||
['aisLiveVessels', fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8'),
|
||||
/hasContact\(mmsi\) \{\s*\n\s*if \(!state\.enabled \|\| !state\.vesselMap \|\| state\.vesselMap\.size === 0\) return null;/],
|
||||
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||
]) {
|
||||
assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`);
|
||||
}
|
||||
@@ -1527,14 +1528,14 @@ test('a deliberate source clear still fully clears the subject', () => {
|
||||
test('production eviction sites actually tag their clears', () => {
|
||||
// The event contract above is worthless if the real cull paths never set the
|
||||
// origin, so pin the three production call sites.
|
||||
const flightsSource = fs.readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const militarySource = fs.readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
const vesselsSource = fs.readFileSync(new URL('./aisLiveVessels.js', import.meta.url), 'utf8');
|
||||
const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url));
|
||||
const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url));
|
||||
const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url));
|
||||
|
||||
for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) {
|
||||
assert.match(
|
||||
source,
|
||||
/if \(icao24 === _trackedIcao\) \{\s*\n\s*_clearTracking\(false, \{ evicted: true \}\);/,
|
||||
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
|
||||
`${name} must mark its aged-out cull as an eviction`,
|
||||
);
|
||||
assert.match(
|
||||
@@ -1553,9 +1554,9 @@ test('production eviction sites actually tag their clears', () => {
|
||||
// The tag alone is not enough — see the behavioral test in
|
||||
// firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod,
|
||||
// whose registration sweep deletes the record the clear needs to see.
|
||||
const firmsSource = fs.readFileSync(new URL('./firmsHeatmap.js', import.meta.url), 'utf8');
|
||||
const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url));
|
||||
const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });');
|
||||
const lodRebuild = firmsSource.indexOf('renderCurrentLod(true);\n if (reselected) selectFire(reselected);');
|
||||
const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);');
|
||||
assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction');
|
||||
assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding');
|
||||
assert.ok(
|
||||
@@ -1588,9 +1589,9 @@ test('cockpit blocks only non-aircraft Context camera flights', () => {
|
||||
});
|
||||
|
||||
test('vessel entry and selection framing both use the 3 km focus radius', () => {
|
||||
assert.match(militaryAwarenessSource, /const VESSEL_FOCUS_RADIUS_M = 3000;/);
|
||||
assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/);
|
||||
const focusRadiusUses = militaryAwarenessSource.match(
|
||||
/new Cesium\.BoundingSphere\(vessel\.position, VESSEL_FOCUS_RADIUS_M\)/g,
|
||||
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g,
|
||||
) || [];
|
||||
assert.equal(focusRadiusUses.length, 2);
|
||||
});
|
||||
|
||||
+65
-3876
File diff suppressed because it is too large.
Load diff
@@ -1,681 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} from './contextStore.js';
|
||||
import {
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
resolveGroundFloorCellsBounded,
|
||||
} from './groundFloor.js';
|
||||
// The shared batched/chunked/session-cached DEM warm chain. The module name is
|
||||
// historical (it shipped with the FIRMS fire anchors); the mechanism itself is
|
||||
// generic — cold coarse floor cells for a rendered point set, resolved strictly
|
||||
// sequentially so overlapping renders cannot stack requests on the proxy.
|
||||
import { warmFireAnchorFloors } from './fireAnchors.js';
|
||||
import { normalizeMilitaryInstallations } from './militaryInstallationData.js';
|
||||
import { installationFeedback } from './installationFeedback.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
createInstallationsLayer,
|
||||
createInstallationSource,
|
||||
} from '../layers/installations/index.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as context from './contextStore.js';
|
||||
import * as ground from './groundFloor.js';
|
||||
import * as anchors from './fireAnchors.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
|
||||
const LAYER_ID = 'military-installations';
|
||||
const REQUEST_DEBOUNCE_MS = 500;
|
||||
const MAX_VIEWPORT_DEGREES = 10;
|
||||
const MAX_RENDERED = 700;
|
||||
const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
||||
const COLOR_BY_CLASS = {
|
||||
airfield: '#5aa9ff',
|
||||
naval_base: '#48c7d5',
|
||||
range: '#d9a85d',
|
||||
military_land: '#9ca6b0',
|
||||
places_candidate: '#c58cff',
|
||||
};
|
||||
const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
||||
const distanceEndpointScratch = new Cesium.Cartographic();
|
||||
const distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
|
||||
|
||||
/**
|
||||
* Allocation-free spherical distance used only as a conservative rejection
|
||||
* pass before the exact ellipsoidal geodesic calculation.
|
||||
*/
|
||||
export function approximateSurfaceDistanceM(latitudeARad, longitudeARad, latitudeBDeg, longitudeBDeg) {
|
||||
const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg);
|
||||
const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg);
|
||||
const latitudeDelta = latitudeBRad - latitudeARad;
|
||||
const longitudeDelta = Math.atan2(
|
||||
Math.sin(longitudeBRad - longitudeARad),
|
||||
Math.cos(longitudeBRad - longitudeARad),
|
||||
);
|
||||
const sinLatitude = Math.sin(latitudeDelta / 2);
|
||||
const sinLongitude = Math.sin(longitudeDelta / 2);
|
||||
const haversine = sinLatitude * sinLatitude
|
||||
+ Math.cos(latitudeARad) * Math.cos(latitudeBRad) * sinLongitude * sinLongitude;
|
||||
return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine)));
|
||||
}
|
||||
|
||||
const state = {
|
||||
viewer: null,
|
||||
dataSource: null,
|
||||
enabled: false,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
status: 'idle',
|
||||
stale: false,
|
||||
/** Whether the upstream truncated at its element cap for the current view. */
|
||||
saturated: false,
|
||||
loading: false,
|
||||
abort: null,
|
||||
/** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */
|
||||
retryTimer: null,
|
||||
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
|
||||
retryDelayMs: 0,
|
||||
retryAt: 0,
|
||||
failureReason: null,
|
||||
moveEndRemove: null,
|
||||
clickHandler: null,
|
||||
timer: null,
|
||||
googleSearchRequested: false,
|
||||
};
|
||||
|
||||
function colorFor(record) {
|
||||
return Cesium.Color.fromCssColorString(COLOR_BY_CLASS[record.class] || '#9ca6b0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Places text-search result without turning a name match into a
|
||||
* mapped military-land claim. Google currently has no documented military
|
||||
* Places type, so ordinary results remain visually distinct candidates; the
|
||||
* explicit branch is retained for any source response that does carry one.
|
||||
* @param {object} place Google Places result.
|
||||
* @returns {string|null} Installation class, or null when not authoritative.
|
||||
*/
|
||||
export function classifyGoogleMilitaryPlace(place) {
|
||||
const types = new Set([
|
||||
place?.primaryType,
|
||||
...(Array.isArray(place?.types) ? place.types : []),
|
||||
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
|
||||
return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type))
|
||||
? 'military_land'
|
||||
: 'places_candidate';
|
||||
}
|
||||
|
||||
/** @param {object} record @returns {string} Human-readable source attribution. */
|
||||
export function installationSourceLabel(record) {
|
||||
const names = [...new Set((Array.isArray(record?.sources) ? record.sources : [])
|
||||
.map((source) => String(source?.name || '').trim())
|
||||
.filter(Boolean))];
|
||||
return names.join(' + ') || 'Unknown mapped source';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared rendered-surface height for an installation anchor or footprint.
|
||||
* @param {{latitude:number, longitude:number}} record Installation record.
|
||||
* @returns {number} Ellipsoidal render height in metres.
|
||||
*/
|
||||
export function installationSurfaceHeightM(record) {
|
||||
return floorAltitudeM(
|
||||
null,
|
||||
cachedGroundFloor(record?.latitude, record?.longitude),
|
||||
) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mapped record belongs to the REQUESTED viewport.
|
||||
*
|
||||
* The proxy snaps the request bbox outward onto a shared cache grid, so a
|
||||
* response is a SUPERSET of what was asked for, and rendering that superset
|
||||
* would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY"
|
||||
* context claim. What may be tested depends on how much of a feature's geometry
|
||||
* we actually hold:
|
||||
*
|
||||
* - A NODE is a point: its centre IS its whole geometry, so an exact
|
||||
* containment test is correct and loses nothing.
|
||||
* - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox
|
||||
* queries return features that merely INTERSECT the box, so centre-testing
|
||||
* these would drop large bases whose centre sits just outside.
|
||||
* - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on
|
||||
* their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without
|
||||
* one, so their true extent is unknown here — and Overpass already proved
|
||||
* they intersect the queried bbox. Centre-testing them would erase exactly
|
||||
* the biggest installations. The honest cost is slight over-inclusion,
|
||||
* bounded by one snap cell (~5.5 km) around the viewport.
|
||||
*
|
||||
* @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record
|
||||
* @param {{south:number, west:number, north:number, east:number}} box Requested viewport.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function installationWithinViewport(record, box) {
|
||||
if (!record || !box) return false;
|
||||
const { latitude, longitude, footprint } = record;
|
||||
const centreInside = latitude >= box.south && latitude <= box.north
|
||||
&& longitude >= box.west && longitude <= box.east;
|
||||
if (centreInside) return true;
|
||||
if (Array.isArray(footprint) && footprint.length) {
|
||||
let minLat = Infinity; let maxLat = -Infinity;
|
||||
let minLon = Infinity; let maxLon = -Infinity;
|
||||
for (const [lon, lat] of footprint) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
}
|
||||
return maxLat >= box.south && minLat <= box.north
|
||||
&& maxLon >= box.west && minLon <= box.east;
|
||||
}
|
||||
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
|
||||
return record.osmType !== 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a response was truncated at the upstream element cap.
|
||||
*
|
||||
* The proxy states this outright, but a `saturated`-less payload is NOT
|
||||
* evidence of a complete answer: entries cached before the saturation guard
|
||||
* shipped predate the field and live for 30 days. Fall back to deriving it from
|
||||
* the element count against the cap the payload itself reports.
|
||||
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function installationResponseSaturated(payload) {
|
||||
if (typeof payload?.saturated === 'boolean') return payload.saturated;
|
||||
const cap = Number(payload?.elementCap);
|
||||
if (!Number.isFinite(cap) || cap <= 0) return false;
|
||||
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a status/error transition and buy the one frame it needs.
|
||||
*
|
||||
* With the render governor idle — Contacts has released its hold and nothing
|
||||
* else animates — no frame would otherwise arrive to re-read this, so a load
|
||||
* that fails after the scene went quiet would leave the last healthy readout on
|
||||
* screen indefinitely.
|
||||
* @param {string} status @param {?string} error
|
||||
*/
|
||||
function setInstallationStatus(status, error = null) {
|
||||
if (state.status === status && state.error === error) return;
|
||||
state.status = status;
|
||||
state.error = error;
|
||||
governorRequestRender('installations-status');
|
||||
}
|
||||
|
||||
function viewportBox(viewer) {
|
||||
const rectangle = viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid);
|
||||
if (!rectangle) return null;
|
||||
const south = Cesium.Math.toDegrees(rectangle.south);
|
||||
const north = Cesium.Math.toDegrees(rectangle.north);
|
||||
const west = Cesium.Math.toDegrees(rectangle.west);
|
||||
const east = Cesium.Math.toDegrees(rectangle.east);
|
||||
// Cross-dateline/global views require a zoom before a bounded request.
|
||||
if (!Number.isFinite(south + north + west + east) || east <= west || north - south > MAX_VIEWPORT_DEGREES || east - west > MAX_VIEWPORT_DEGREES) return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
||||
* the selected one when it falls outside that window.
|
||||
*
|
||||
* Context navigation walks the FULL nearby cohort, which is not bounded by the
|
||||
* render cap, so selecting item 701+ used to produce no entity at all — the
|
||||
* camera flew, `getById` returned null, and the selection was silently dropped
|
||||
* on the floor, leaving the Context subject stale so NEXT offered the same
|
||||
* installation forever. One extra entity keeps every cohort item selectable
|
||||
* and the cohort count honest.
|
||||
* @returns {Array<object>} Records to render this paint.
|
||||
*/
|
||||
function renderableRecords() {
|
||||
const rendered = state.records.slice(0, MAX_RENDERED);
|
||||
if (!state.selectedId) return rendered;
|
||||
if (rendered.some((record) => record.id === state.selectedId)) return rendered;
|
||||
const selected = state.recordById.get(state.selectedId);
|
||||
return selected ? [...rendered, selected] : rendered;
|
||||
}
|
||||
|
||||
function renderRecords({ claimSelection = false } = {}) {
|
||||
// Context navigation can select another layer without a canvas click.
|
||||
// A delayed floor/data repaint must not steal that newer selection back.
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) {
|
||||
state.selectedId = null;
|
||||
}
|
||||
// Post-moveEnd debounced fetches commit after the camera settles; the
|
||||
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
|
||||
governorRequestRender('installations-render');
|
||||
clearRendered();
|
||||
for (const record of renderableRecords()) {
|
||||
const color = colorFor(record);
|
||||
const surfaceHeightM = installationSurfaceHeightM(record);
|
||||
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
surfaceHeightM,
|
||||
);
|
||||
const entity = state.dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: displayPosition,
|
||||
point: {
|
||||
pixelSize: record.id === state.selectedId ? 13 : 9,
|
||||
color: record.id === state.selectedId ? Cesium.Color.WHITE : color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
polygon: record.footprint ? {
|
||||
hierarchy: new Cesium.PolygonHierarchy(record.footprint.map(([longitude, latitude]) => Cesium.Cartesian3.fromDegrees(longitude, latitude))),
|
||||
material: color.withAlpha(0.12),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(0.65),
|
||||
height: surfaceHeightM,
|
||||
} : undefined,
|
||||
});
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || 'MAPPED INSTALLATION',
|
||||
details: [String(record.class || 'installation').replaceAll('_', ' ').toUpperCase()],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
};
|
||||
registerEntityContext(entity, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
layerName: record.kind === 'place_candidate'
|
||||
? 'Military Site Search Candidates'
|
||||
: 'Mapped Military Installations',
|
||||
source: installationSourceLabel(record),
|
||||
label: record.name,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
retrievedAt: record.retrievedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
const selectedEntity = state.selectedId
|
||||
? state.dataSource.entities.getById(state.selectedId)
|
||||
: null;
|
||||
if (selectedEntity) selectEntityContext(selectedEntity);
|
||||
else state.selectedId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second paint for floors that missed the bounded pre-render deadline.
|
||||
*
|
||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
||||
* and lands seconds later, and without this the records it covers stay pinned
|
||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
||||
* 2026-08-18: "orange dots at the bottom").
|
||||
*
|
||||
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
||||
* difference the installations path forces: the trigger is whether a cell that
|
||||
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
|
||||
* it. The bounded resolve above is still running against the same cells, so
|
||||
* asking "did MY batch warm anything" would answer false exactly when the other
|
||||
* resolve won the race — the common case. Still terminating: a set that is
|
||||
* wholly cold afterwards re-renders zero times and the next camera-driven load
|
||||
* retries.
|
||||
* @param {Array<object>} records Records just rendered.
|
||||
* @returns {void}
|
||||
*/
|
||||
function warmInstallationFloors(records) {
|
||||
const cold = records
|
||||
.filter((record) => cachedGroundFloor(record.latitude, record.longitude) == null)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
warmFireAnchorFloors(cold).then(() => {
|
||||
if (!state.enabled || !state.dataSource) return;
|
||||
if (!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)) return;
|
||||
renderRecords();
|
||||
});
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const record = state.recordById.get(id);
|
||||
if (!record || !state.dataSource) return false;
|
||||
state.selectedId = id;
|
||||
renderRecords({ claimSelection: true });
|
||||
// renderRecords drops selectedId when the record produced no entity.
|
||||
return state.selectedId === id;
|
||||
}
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (state.clickHandler) return;
|
||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
state.clickHandler.setInputAction((click) => {
|
||||
if (!state.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
|
||||
if (id && state.recordById.has(id) && id !== state.selectedId) {
|
||||
selectRecord(id);
|
||||
} else if (state.selectedId) {
|
||||
// Clicking the selected site again, empty map, or another contact
|
||||
// releases this layer's selection. Clear only our shared context so a
|
||||
// sibling click handler's newly selected aircraft/site stays intact.
|
||||
state.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
renderRecords();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
||||
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
||||
*/
|
||||
export function installationRetryDelayMs(prevDelayMs) {
|
||||
const RETRY_MIN_MS = 30000;
|
||||
const RETRY_CEIL_MS = 240000;
|
||||
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
||||
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only
|
||||
* on enable and on camera moveEnd, so a parked camera whose first request died
|
||||
* (one flaky Overpass mirror is enough) stayed unavailable forever while the
|
||||
* proxy sat healthy while the layer refused to show its features. While the
|
||||
* layer is enabled and
|
||||
* unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load,
|
||||
* zoom-out, or disable cancels it.
|
||||
*/
|
||||
function scheduleUnavailableRetry() {
|
||||
if (!state.enabled) return;
|
||||
clearTimeout(state.retryTimer);
|
||||
state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs);
|
||||
state.retryAt = Date.now() + state.retryDelayMs;
|
||||
state.retryTimer = setTimeout(() => {
|
||||
state.retryTimer = null;
|
||||
state.retryAt = 0;
|
||||
if (state.enabled && !state.loading) loadInstallations();
|
||||
}, state.retryDelayMs);
|
||||
}
|
||||
|
||||
function clearUnavailableRetry({ resetBackoff = true } = {}) {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.retryTimer = null;
|
||||
state.retryAt = 0;
|
||||
if (resetBackoff) state.retryDelayMs = 0;
|
||||
}
|
||||
|
||||
function scheduleLoad() {
|
||||
if (!state.enabled) return;
|
||||
// A user-driven load supersedes any pending retry; the load reschedules on
|
||||
// failure, so the backoff step is kept rather than reset.
|
||||
clearUnavailableRetry({ resetBackoff: false });
|
||||
clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => { loadInstallations(); }, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function loadInstallations() {
|
||||
if (!state.enabled || !state.viewer) return;
|
||||
const box = viewportBox(state.viewer);
|
||||
// Guidance, not a fault: the layer chose not to query because the view is
|
||||
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
|
||||
// of `error` — the manager derives refresh failures and the global status
|
||||
// chip derives LOAD FAILED from that field, and a "zoom in" prompt turned
|
||||
// every Cockpit refresh into a reported failure. The row and toast read the
|
||||
// prompt from `statusMessage` (installationFeedback) instead.
|
||||
if (!box) {
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
clearUnavailableRetry();
|
||||
setInstallationStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
state.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
state.abort = requestAbort;
|
||||
state.loading = true;
|
||||
clearUnavailableRetry({ resetBackoff: false });
|
||||
// The previous attempt's failure is not the outcome of this new attempt.
|
||||
setInstallationStatus('loading');
|
||||
try {
|
||||
const fetchInstallations = async (exact) => {
|
||||
const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)]));
|
||||
if (exact) query.set('exact', '1');
|
||||
const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), {
|
||||
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable',
|
||||
});
|
||||
return body;
|
||||
};
|
||||
|
||||
let payload = await fetchInstallations(false);
|
||||
// A SATURATED snapped tile was truncated upstream, so features from the
|
||||
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
||||
// for the exact viewport (separately keyed and cached) before rendering.
|
||||
let saturated = installationResponseSaturated(payload);
|
||||
if (saturated) {
|
||||
payload = await fetchInstallations(true);
|
||||
saturated = installationResponseSaturated(payload);
|
||||
}
|
||||
const normalized = normalizeMilitaryInstallations(payload, payload.retrievedAt || new Date().toISOString());
|
||||
// The proxy answers a bbox at least as large as the viewport; keep only what
|
||||
// was actually asked for so nothing off-screen reaches the map or the
|
||||
// "current viewport only" context claim.
|
||||
const records = normalized.records.filter((record) => installationWithinViewport(record, box));
|
||||
let placesError = null;
|
||||
if (state.googleSearchRequested) {
|
||||
state.googleSearchRequested = false;
|
||||
const latitude = (box.south + box.north) / 2;
|
||||
const longitude = (box.west + box.east) / 2;
|
||||
const radiusM = Math.min(50000, Math.max(1000, Math.round(Math.max(box.north - box.south, box.east - box.west) * 55_000)));
|
||||
try {
|
||||
const placesResponse = await fetch(`/api/google/text-search?${new URLSearchParams({
|
||||
q: 'military installation', lat: latitude.toFixed(5), lon: longitude.toFixed(5), radiusM: String(radiusM),
|
||||
})}`, { signal: requestAbort.signal });
|
||||
const placesPayload = await placesResponse.json();
|
||||
if (!placesResponse.ok) throw new Error(placesPayload?.error || `Google Places HTTP ${placesResponse.status}`);
|
||||
const seen = new Set(records.map((record) => `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`));
|
||||
for (const place of Array.isArray(placesPayload?.places) ? placesPayload.places : []) {
|
||||
if (!place?.id || !place?.name || !Number.isFinite(place.latitude) || !Number.isFinite(place.longitude)) continue;
|
||||
const placeClass = classifyGoogleMilitaryPlace(place);
|
||||
const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`;
|
||||
if (seen.has(signature)) continue;
|
||||
seen.add(signature);
|
||||
const retrievedAt = new Date().toISOString();
|
||||
records.push({
|
||||
id: `google:${place.id}`,
|
||||
kind: placeClass === 'military_land' ? 'installation' : 'place_candidate',
|
||||
class: placeClass,
|
||||
name: String(place.name).trim(),
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
footprint: null,
|
||||
primaryType: place.primaryType || null,
|
||||
placeTypes: Array.isArray(place.types) ? place.types : [],
|
||||
sources: [{ name: 'Google Maps Places', id: place.id, retrievedAt }],
|
||||
validation: 'unreviewed',
|
||||
retrievedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
placesError = 'Google Places search unavailable; showing mapped sites';
|
||||
}
|
||||
}
|
||||
await resolveGroundFloorCellsBounded(records.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})));
|
||||
if (requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled) return;
|
||||
state.records = records;
|
||||
state.recordById = new Map(state.records.map((record) => [record.id, record]));
|
||||
state.lastUpdate = Date.now();
|
||||
state.stale = payload.status === 'stale';
|
||||
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
||||
// than implying the view is completely surveyed.
|
||||
state.saturated = saturated;
|
||||
state.failureReason = null;
|
||||
clearUnavailableRetry();
|
||||
setInstallationStatus(
|
||||
state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty',
|
||||
payload.status === 'stale'
|
||||
? 'Serving cached mapped context'
|
||||
: (saturated ? 'Too many mapped sites in view to list them all' : placesError),
|
||||
);
|
||||
renderRecords();
|
||||
warmInstallationFloors(state.records);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
state.failureReason = error?.failureReason || 'unavailable';
|
||||
setInstallationStatus('unavailable', error?.message || 'Installation context unavailable');
|
||||
scheduleUnavailableRetry();
|
||||
} finally {
|
||||
// An older aborted request must not clear a newer request's busy state.
|
||||
if (state.abort === requestAbort) {
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const militaryInstallationsLayer = {
|
||||
id: LAYER_ID,
|
||||
name: 'Mapped Installations',
|
||||
icon: '⌖',
|
||||
source: 'OpenStreetMap + optional Google Maps Places',
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
state.viewer = viewer;
|
||||
state.dataSource = new Cesium.CustomDataSource('military-installations');
|
||||
viewer.dataSources.add(state.dataSource);
|
||||
state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
||||
installInteraction(viewer);
|
||||
},
|
||||
enable() {
|
||||
state.enabled = true;
|
||||
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
|
||||
state.dataSource.show = true;
|
||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||
// the first fetch. Avoid racing it with a second aborting request here.
|
||||
},
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
clearUnavailableRetry();
|
||||
clearTimeout(state.timer);
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
if (state.dataSource) state.dataSource.show = false;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
state.selectedId = null;
|
||||
state.failureReason = null;
|
||||
setInstallationStatus('idle');
|
||||
},
|
||||
update() { return loadInstallations(); },
|
||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||
searchNearby() {
|
||||
state.googleSearchRequested = true;
|
||||
return loadInstallations();
|
||||
},
|
||||
destroy(viewer) {
|
||||
this.disable();
|
||||
state.moveEndRemove?.();
|
||||
state.clickHandler?.destroy();
|
||||
state.clickHandler = null;
|
||||
clearRendered();
|
||||
if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true);
|
||||
state.dataSource = null;
|
||||
},
|
||||
getNearby(center, rangeM, maxCount = 50) {
|
||||
if (!center) return [];
|
||||
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
||||
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
|
||||
if (!centerCartographic) return [];
|
||||
const nearby = [];
|
||||
const approximateLimit = Number.isFinite(range)
|
||||
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
|
||||
: Infinity;
|
||||
for (const record of state.records) {
|
||||
if (record.kind !== 'installation') continue;
|
||||
if (approximateSurfaceDistanceM(
|
||||
centerCartographic.latitude,
|
||||
centerCartographic.longitude,
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
) > approximateLimit) continue;
|
||||
// The awareness disk is projected onto the ground. Confirm candidates
|
||||
// with an exact ellipsoidal surface distance and reusable scratch state.
|
||||
distanceEndpointScratch.longitude = Cesium.Math.toRadians(record.longitude);
|
||||
distanceEndpointScratch.latitude = Cesium.Math.toRadians(record.latitude);
|
||||
distanceEndpointScratch.height = 0;
|
||||
distanceGeodesicScratch.setEndPoints(centerCartographic, distanceEndpointScratch);
|
||||
const distanceM = distanceGeodesicScratch.surfaceDistance;
|
||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||
nearby.push({
|
||||
...record,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
installationSurfaceHeightM(record),
|
||||
),
|
||||
distanceM,
|
||||
});
|
||||
}
|
||||
nearby.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return nearby.slice(0, Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50);
|
||||
},
|
||||
/**
|
||||
* Select and frame a mapped installation from another contextual UI.
|
||||
* @param {string} id Source-backed installation id.
|
||||
* @returns {boolean} True when an available installation was focused.
|
||||
*/
|
||||
focusById(id) {
|
||||
const record = state.recordById.get(String(id));
|
||||
if (!record || !state.viewer) return false;
|
||||
// No camera flight without a real selection: a flight plus a stale subject
|
||||
// reads as success to Context navigation and strands NEXT on this item.
|
||||
if (!selectRecord(record.id)) return false;
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
installationSurfaceHeightM(record),
|
||||
),
|
||||
18000,
|
||||
),
|
||||
{ duration: 1.4 },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: state.records.length,
|
||||
lastUpdate: state.lastUpdate,
|
||||
stale: state.stale,
|
||||
saturated: state.saturated,
|
||||
error: state.error,
|
||||
status: state.status,
|
||||
loading: state.loading,
|
||||
retryAt: state.retryAt,
|
||||
retrying: state.loading && Boolean(state.failureReason),
|
||||
failureReason: state.failureReason,
|
||||
statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }),
|
||||
loadingLabel: state.loading ? 'loading mapped installation context' : '',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default militaryInstallationsLayer;
|
||||
const layer = createInstallationsLayer({
|
||||
source: createInstallationSource(),
|
||||
services: { render, context, ground, anchors, picking },
|
||||
});
|
||||
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
|
||||
export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace;
|
||||
export const installationSourceLabel = layer.installationSourceLabel;
|
||||
export const installationSurfaceHeightM = layer.installationSurfaceHeightM;
|
||||
export const installationWithinViewport = layer.installationWithinViewport;
|
||||
export const installationResponseSaturated =
|
||||
layer.installationResponseSaturated;
|
||||
export const installationRetryDelayMs = layer.installationRetryDelayMs;
|
||||
export default layer;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const installationsSource = fs.readFileSync(
|
||||
const installationsSource = readLayerSource(
|
||||
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
||||
|
||||
test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => {
|
||||
@@ -797,10 +798,10 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
||||
|
||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
assert.match(installationsSource,
|
||||
/setInstallationStatus\('unavailable',[^]*?\);\n\s*scheduleUnavailableRetry\(\);/,
|
||||
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||
'a failed load schedules the retry immediately after reporting unavailable');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*state\.records\.length/,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||
'a successful load clears the pending retry and resets the backoff');
|
||||
assert.match(installationsSource,
|
||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/,
|
||||
@@ -811,6 +812,6 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||
/function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/,
|
||||
'a user-driven load supersedes the retry without resetting the backoff step');
|
||||
assert.match(installationsSource,
|
||||
/state\.enabled && !state\.loading\) loadInstallations\(\)/,
|
||||
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||
'the fired retry re-checks enablement and never races an in-flight load');
|
||||
});
|
||||
+10
-118
@@ -1,119 +1,11 @@
|
||||
/**
|
||||
* Shared military-aircraft ICAO24 registry (2026-06-10 playtest fix).
|
||||
*
|
||||
* adsb.lol /v2/mil tags aircraft via its database's military flag; OpenSky
|
||||
* carries no such tag, so military aircraft (e.g. ADAPT91/92) appeared in
|
||||
* BOTH layers as duplicate icons and tracks. This registry reconciles them:
|
||||
*
|
||||
* - The military layer, while enabled, feeds every poll's ICAO set here and
|
||||
* marks itself active — the commercial flights layer then SUPPRESSES its
|
||||
* duplicates (military layer wins icon, track, and click).
|
||||
* - While the military layer is OFF, the flights layer keeps a low-rate
|
||||
* poll (60s against the dev proxy's cached /api/adsblol/mil) so known
|
||||
* military aircraft are still classified and styled amber.
|
||||
*/
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
|
||||
const MIL_POLL_INTERVAL_MS = 60000;
|
||||
|
||||
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
|
||||
const _milIcaos = new Set();
|
||||
/** @type {boolean} True while the dedicated military layer is enabled. */
|
||||
let _militaryLayerActive = false;
|
||||
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
|
||||
const _activeChangeListeners = new Set();
|
||||
/** @type {number} Epoch ms of the last registry refresh (any source). */
|
||||
let _lastRefreshMs = 0;
|
||||
/** @type {boolean} A self-poll fetch is in flight. */
|
||||
let _polling = false;
|
||||
|
||||
/**
|
||||
* True when the dedicated military layer currently renders these aircraft
|
||||
* (the flights layer should suppress duplicates rather than restyle them).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMilitaryLayerActive() {
|
||||
return _militaryLayerActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the dedicated military layer enabled/disabled. On a TRANSITION
|
||||
* (value actually changed) the registered change listeners fire so the
|
||||
* flights layer can reconcile its duplicates immediately instead of waiting
|
||||
* out its 30 s poll (pre-ship audit M2).
|
||||
* @param {boolean} active - Whether the military layer renders.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setMilitaryLayerActive(active) {
|
||||
const next = !!active;
|
||||
if (next === _militaryLayerActive) return;
|
||||
_militaryLayerActive = next;
|
||||
for (const listener of _activeChangeListeners) {
|
||||
try {
|
||||
listener(next);
|
||||
} catch {
|
||||
// a broken listener must never break the layer toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to military-layer active-state transitions (fired only when the
|
||||
* value changes, AFTER the new state is committed).
|
||||
* @param {(active: boolean) => void} listener - Change callback.
|
||||
* @returns {() => void} Unsubscribe function.
|
||||
*/
|
||||
export function onMilitaryLayerActiveChange(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
_activeChangeListeners.add(listener);
|
||||
return () => _activeChangeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces/extends the known-military set from a fresh poll.
|
||||
* Adds only — transient dropouts from one poll must not declassify an
|
||||
* aircraft mid-session (the set stays small: a few hundred hexes).
|
||||
* @param {Iterable<string>} icaos - ICAO24 hexes from a /v2/mil response.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function registerMilitaryIcaos(icaos) {
|
||||
for (const icao of icaos || []) {
|
||||
const hex = String(icao || '').trim().toLowerCase();
|
||||
if (hex) _milIcaos.add(hex);
|
||||
}
|
||||
_lastRefreshMs = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft is known military.
|
||||
* @param {string} icao24 - ICAO24 hex (any case).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMilitaryIcao(icao24) {
|
||||
return _milIcaos.has(String(icao24 || '').toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the registry from /api/adsblol/mil when stale — used by the
|
||||
* flights layer so classification works while the military layer is off
|
||||
* (the military layer's own polls keep it fresh otherwise). The dev proxy
|
||||
* caches upstream responses, so this is nearly free.
|
||||
* @returns {void} Fire-and-forget; failures leave the current set intact.
|
||||
*/
|
||||
export function refreshMilitaryRegistryIfStale() {
|
||||
if (_militaryLayerActive) return; // military layer's polls keep us fresh
|
||||
if (_polling || (Date.now() - _lastRefreshMs) < MIL_POLL_INTERVAL_MS) return;
|
||||
_polling = true;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/adsblol/mil', { signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const aircraft = Array.isArray(data?.ac) ? data.ac : [];
|
||||
registerMilitaryIcaos(aircraft.map((entry) => entry?.hex));
|
||||
} catch {
|
||||
// keep the existing set on any failure
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
|
||||
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
|
||||
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
|
||||
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
|
||||
export const registerMilitaryIcaos = registry.registerMilitaryIcaos;
|
||||
export const isMilitaryIcao = registry.isMilitaryIcao;
|
||||
export const refreshMilitaryRegistryIfStale =
|
||||
registry.refreshMilitaryRegistryIfStale;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// Regression test for the giant-military-jet bug: MODEL_SCALE must put every
|
||||
// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is.
|
||||
//
|
||||
@@ -358,7 +359,7 @@ function nativeVisualCenter(file) {
|
||||
// --- per-layer constants, read from source (see header) --------------------
|
||||
|
||||
function layerConstants(sourceFile) {
|
||||
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
|
||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||
const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/);
|
||||
assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`);
|
||||
const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/);
|
||||
@@ -367,8 +368,8 @@ function layerConstants(sourceFile) {
|
||||
}
|
||||
|
||||
function normalBillboardScaleByDistance(sourceFile) {
|
||||
const src = fs.readFileSync(path.join(ROOT, sourceFile), 'utf8');
|
||||
const fn = src.match(/function _normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
|
||||
const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
|
||||
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
|
||||
@@ -391,7 +392,7 @@ const LAYERS = [
|
||||
name: 'military',
|
||||
source: 'src/data/militaryFlights.js',
|
||||
asset: (() => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
|
||||
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
|
||||
const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/);
|
||||
assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found');
|
||||
return m[1];
|
||||
@@ -491,7 +492,7 @@ for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) {
|
||||
// PLANE_* constants — pin them to the measured meter-scale GLB + flights'
|
||||
// calibration so the copies cannot drift.
|
||||
test('military layer airplane.glb constants match the measured GLB and flights calibration', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/data/militaryFlights.js'), 'utf8');
|
||||
const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js'));
|
||||
const grab = (name) => {
|
||||
const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`));
|
||||
assert.ok(m, `militaryFlights.js: ${name} not found`);
|
||||
|
||||
+97
-2874
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeRadioCountryInput } from './radioCountry.js';
|
||||
import { normalizeRadioCountryInput } from '../layers/radio/index.js';
|
||||
|
||||
test('Radio country normalization maps ISO codes and bounded common names', () => {
|
||||
for (const [input, code] of [
|
||||
|
||||
+79
-3697
File diff suppressed because it is too large.
Load diff
+51
-2394
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -113,8 +114,8 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
|
||||
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
|
||||
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
||||
assert.match(
|
||||
createFirmsHeatmapLayer.toString(),
|
||||
/registerSpriteCollection\('firms', _billboards\);\s*restoreSpriteOrder\(_viewer\);/,
|
||||
createFirmsRendering.toString(),
|
||||
/registerSpriteCollection\('firms', layerState\._billboards\);\s*restoreSpriteOrder\(layerState\._viewer\);/,
|
||||
'lazy FIRMS registration must restore order immediately',
|
||||
);
|
||||
});
|
||||
@@ -496,6 +496,14 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
let _cableDataSource = null;
|
||||
let _landingDataSource = null;
|
||||
let _referenceDataSource = null;
|
||||
/**
|
||||
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
|
||||
* the three data sources (see releaseDataSources) instead of hiding them,
|
||||
* so a re-enable has to rebuild the entities; the network/parse half of
|
||||
* that rebuild is the expensive part users would notice, so it is cached.
|
||||
*/
|
||||
let _cachedCableJson = null;
|
||||
let _cachedLandingJson = null;
|
||||
let _referenceRecords = [];
|
||||
let _surfaceRecords = [];
|
||||
let _clickHandler = null;
|
||||
@@ -553,11 +561,17 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
const owns = () => generation === _loadGeneration && !abort.signal.aborted;
|
||||
|
||||
try {
|
||||
const [cableJson, landingJson] = await Promise.all([
|
||||
fetchJson(cableUrl, abort.signal),
|
||||
fetchJson(landingPointUrl, abort.signal),
|
||||
]);
|
||||
if (!owns()) return;
|
||||
let cableJson = _cachedCableJson;
|
||||
let landingJson = _cachedLandingJson;
|
||||
if (!cableJson || !landingJson) {
|
||||
[cableJson, landingJson] = await Promise.all([
|
||||
fetchJson(cableUrl, abort.signal),
|
||||
fetchJson(landingPointUrl, abort.signal),
|
||||
]);
|
||||
if (!owns()) return;
|
||||
_cachedCableJson = cableJson;
|
||||
_cachedLandingJson = landingJson;
|
||||
}
|
||||
|
||||
const cableFeatures = normalizeFeatures(cableJson, 'cable');
|
||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||
@@ -706,6 +720,37 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the three data sources from the viewer and forget the built
|
||||
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
|
||||
* DataSourceDisplay still walks every visualizer of a hidden source on
|
||||
* every frame, and the thousands of hidden polylines/billboards keep
|
||||
* costing CPU and GC pressure long after the user toggled the layer off —
|
||||
* that is the "everything gets sluggish after I turn layers off" report.
|
||||
* The fetched JSON stays cached, so the next enable rebuilds without a
|
||||
* network round trip or a re-parse.
|
||||
*/
|
||||
function releaseDataSources(viewer) {
|
||||
const sources = [_cableDataSource, _landingDataSource, _referenceDataSource];
|
||||
_cableDataSource = null;
|
||||
_landingDataSource = null;
|
||||
_referenceDataSource = null;
|
||||
_referenceRecords = [];
|
||||
_surfaceRecords = [];
|
||||
_pickByEntity = new WeakMap();
|
||||
_referenceLabelCount = 0;
|
||||
_publishScratch.length = 0;
|
||||
// The blend pass targets the collections being dropped; the rebuilt
|
||||
// sources need their own pass.
|
||||
_markerBlendDone = false;
|
||||
_loaded = false;
|
||||
_referenceSweepGate.reset();
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
if (_cableDataSource) _cableDataSource.show = _enabled;
|
||||
if (_landingDataSource) _landingDataSource.show = _enabled;
|
||||
@@ -1093,7 +1138,6 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
updateVisibility();
|
||||
_overlayPublisher.hide();
|
||||
resetPublishSignature();
|
||||
if (_loading && _abort) {
|
||||
@@ -1101,6 +1145,10 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
_loading = false;
|
||||
_loadingLabel = '';
|
||||
}
|
||||
// Free the entities rather than hide them; enable() rebuilds from the
|
||||
// cached JSON. An in-flight load's own post-await ownership check
|
||||
// removes whatever that stale generation adds later.
|
||||
releaseDataSources(_viewer);
|
||||
},
|
||||
|
||||
update(viewer) {
|
||||
@@ -1109,9 +1157,9 @@ export function createTeleGeographySubmarineCableLayer({
|
||||
|
||||
destroy(viewer) {
|
||||
if (_abort) _abort.abort();
|
||||
if (_cableDataSource && viewer) viewer.dataSources.remove(_cableDataSource, true);
|
||||
if (_landingDataSource && viewer) viewer.dataSources.remove(_landingDataSource, true);
|
||||
if (_referenceDataSource && viewer) viewer.dataSources.remove(_referenceDataSource, true);
|
||||
releaseDataSources(viewer || _viewer);
|
||||
_cachedCableJson = null;
|
||||
_cachedLandingJson = null;
|
||||
// hide() clears every published host entry and goes invisible while
|
||||
// keeping the publisher reusable — the legacy layer supported re-init
|
||||
// after destroy, and hidden publishers already drop late publishes.
|
||||
|
||||
@@ -743,7 +743,15 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b
|
||||
env.layer.disable();
|
||||
env.layer.enable(env.viewer);
|
||||
await env.settle();
|
||||
assert.equal(geojson.calls.length, 1, 'stale load A must not start the landing GeoJson build');
|
||||
// A already cached the fetched JSON, so load B skips the network and goes
|
||||
// straight to its own CABLE build; the only new GeoJson call must be that
|
||||
// one — a landing build here would mean stale A ran on past its check.
|
||||
assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching');
|
||||
assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build');
|
||||
assert.ok(
|
||||
Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke),
|
||||
'the second GeoJson call is load B\'s cable build, not A\'s landing build',
|
||||
);
|
||||
|
||||
env.releaseAll();
|
||||
await env.settle();
|
||||
@@ -1254,3 +1262,36 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i
|
||||
|
||||
env.layer.destroy(env.viewer);
|
||||
});
|
||||
|
||||
test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => {
|
||||
const env = await createRealCableLayerHarness();
|
||||
assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on');
|
||||
const before = env.layer.getStats().count;
|
||||
assert.equal(before, 2);
|
||||
|
||||
// Any fetch after this point is a bug: the rebuild must come from the cache.
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetches = 0;
|
||||
globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); };
|
||||
try {
|
||||
env.layer.disable();
|
||||
assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources');
|
||||
|
||||
env.layer.enable(env.viewer);
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline && env.dataSources.length < 3) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(fetches, 0, 'the rebuild must not touch the network');
|
||||
assert.equal(env.layer.getStats().error, null);
|
||||
assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio');
|
||||
assert.equal(env.layer.getStats().count, before);
|
||||
const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
|
||||
assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt');
|
||||
|
||||
env.layer.destroy(env.viewer);
|
||||
assert.equal(env.dataSources.length, 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/tr3bRegistry.test.mjs
|
||||
// TR-3B conversion Easter egg: registry state, sprite-variant selection,
|
||||
// class-label override, and the render-path invariants that keep a converted
|
||||
@@ -227,7 +228,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
|
||||
test('both flight layers keep a converted contact 2D and visible (render invariants)', async () => {
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
|
||||
// 1. The 3D model handoff is SUPPRESSED for a converted contact — there is
|
||||
// no TR-3B GLB, so the triangle billboard stays the visual.
|
||||
@@ -237,13 +238,13 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
// (2026-08-19), so the suppression moved from a conjunct on
|
||||
// `_modelRegimeActive()` to an explicit early return. The invariant is
|
||||
// unchanged: a converted contact never reaches the model handoff.
|
||||
assert.match(source, /if \(!_trackedIcao \|\| _cockpitContactMode \|\| isTr3b\(_trackedIcao\)\) \{/,
|
||||
assert.match(source, /if\s*\(\s*!(?:flightState\.)?_trackedIcao\s*\|\|\s*(?:flightState\.)?_cockpitContactMode\s*\|\|\s*isTr3b\(\s*(?:flightState\.)?_trackedIcao,?\s*\),?\s*\)\s*\{/,
|
||||
`${name}: the standalone tracked model is suppressed for a converted contact`);
|
||||
|
||||
// 2. The billboard is never hidden by that suppression — it must keep
|
||||
// satisfying the getNearby/getDetectableObjects visibility guards, so a
|
||||
// converted contact still works in Contacts and Cockpit.
|
||||
assert.match(source, /if \(bb && id !== _trackedIcao\) bb\.show = true;|if \(modelled && id !== _trackedIcao\) modelled\.show = true;/,
|
||||
assert.match(source, /if\s*\(\s*bb\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*bb\.show\s*=\s*true;|if\s*\(\s*modelled\s*&&\s*id\s*!==\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*modelled\.show\s*=\s*true;/,
|
||||
`${name}: converting restores the billboard the model handoff had hidden`);
|
||||
|
||||
// 3. Every aircraftIcon() CALL SITE routes through the kind resolver, so no
|
||||
@@ -253,7 +254,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
|
||||
const callSites = code.match(/aircraftIcon\(\s*[^;]*?\)/g) || [];
|
||||
assert.equal(callSites.length >= 4, true, `${name}: expected the known aircraftIcon call sites`);
|
||||
for (const call of callSites) {
|
||||
assert.match(call, /aircraftIcon\(\s*_iconKind\(/,
|
||||
assert.match(call, /aircraftIcon\(\s*\s*(?:parts\.rendering\.)?_iconKind\(\s*/,
|
||||
`${name}: ${call.replace(/\s+/g, ' ')} must resolve its sprite kind through _iconKind`);
|
||||
}
|
||||
|
||||
@@ -270,8 +271,8 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
// personally clicked, and re-tracking the same aircraft after a layer restart
|
||||
// should still show the triangle. Only a page reload resets it — so no
|
||||
// production code may clear the registry.
|
||||
for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) {
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.doesNotMatch(source, /clearTr3bRegistry/,
|
||||
`${name}: teardown must not clear session conversions`);
|
||||
}
|
||||
@@ -406,10 +407,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
|
||||
// in the fleet tick (no seam to drive headlessly), so this asserts the guard's
|
||||
// POSITION rather than replaying the four-pass selection.
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
// Anchor on the MODEL-eligibility loop (keepDistSq), not the unrelated
|
||||
// ambient-enrichment candidate loop that also builds a `cand`.
|
||||
const loop = /const cand = \[\];\s*\n\s*for \(const \[icao, bb\] of _billboards\)[\s\S]*?cand\.push\(/.exec(source)?.[0];
|
||||
const loop = /const\s*cand\s*=\s*\[\];\s*\n\s*for\s*\(\s*const\s*\[icao,\s*bb\]\s*of\s*(?:flightState\.)?_billboards,?\s*\)[\s\S]*?cand\.push\(\s*/.exec(source)?.[0];
|
||||
assert.ok(loop, `${name}: the model-eligibility candidate loop is present`);
|
||||
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
// src/data/trackedModelRegime.test.mjs
|
||||
//
|
||||
// Zoom-driven 2D↔3D for the TRACKED contact (owner directive 2026-08-19).
|
||||
@@ -931,15 +932,15 @@ for (const fixture of LAYERS) {
|
||||
test('both layers gate the tracked-model load and record its failures', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /!_trackedModel && !_trackedModelLoading && _trackedModelLoadAllowed\(\)/,
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
|
||||
`${name}: the driver asks permission before starting another tracked-model load`);
|
||||
assert.match(source, /_noteTrackedModelLoadFailure\(trackedSpec\.url, err\)/,
|
||||
assert.match(source, /(?:parts\.\w+\.)?_noteTrackedModelLoadFailure\(\s*trackedSpec\.url,\s*err,?\s*\)/,
|
||||
`${name}: a rejected load is recorded against this selection, not silently retried`);
|
||||
// The reset must be reachable from the real teardown, not only a test seam.
|
||||
assert.match(source, /_releaseTrackedModel\(\);\n _resetTrackedSelectionState\(\);/,
|
||||
assert.match(source, /(?:parts\.\w+\.)?_releaseTrackedModel\(\s*,?\s*\);\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
|
||||
`${name}: deselect clears the per-selection latches in the production path`);
|
||||
assert.match(source, /_trackedIcao = icao24;\n _resetTrackedSelectionState\(\);/,
|
||||
assert.match(source, /(?:flightState\.)?_trackedIcao\s*=\s*icao24;\n\s*(?:parts\.\w+\.)?_resetTrackedSelectionState\(\s*,?\s*\);/,
|
||||
`${name}: selecting a contact starts from a clean per-selection state`);
|
||||
}
|
||||
});
|
||||
@@ -947,16 +948,16 @@ test('both layers gate the tracked-model load and record its failures', async ()
|
||||
test('the tracked regime never consults the fleet models3d toggle', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
for (const name of ['flights.js', 'militaryFlights.js']) {
|
||||
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
|
||||
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.ok(regime, `${name}: _trackedModelRegimeActive is defined`);
|
||||
assert.doesNotMatch(regime, /_models3dEnabled|_modelRegimeActive\(\)/,
|
||||
assert.doesNotMatch(regime, /(?:flightState\.)?_models3dEnabled|(?:parts\.\w+\.)?_modelRegimeActive\(\s*,?\s*\)/,
|
||||
`${name}: the tracked contact's handoff is default-on, not gated on the fleet toggle`);
|
||||
assert.match(regime, /trackedModelZoomActive\(/,
|
||||
`${name}: the tracked contact uses the shared hysteretic zoom policy`);
|
||||
// The FLEET must still obey the toggle — the budget decision stays the operator's.
|
||||
const fleet = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(fleet, /if \(!_models3dEnabled\) return false;/,
|
||||
const fleet = /^([ \t]*)function _modelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
|
||||
assert.match(fleet, /if\s*\(\s*!(?:flightState\.)?_models3dEnabled,?\s*\)\s*return\s*false;/,
|
||||
`${name}: the fleet regime still obeys the DISPLAY-rail 3D toggle`);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -244,14 +245,14 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
'militaryFlights.js',
|
||||
'satellites.js',
|
||||
'militaryInstallations.js',
|
||||
].map(async (name) => [name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]));
|
||||
].map(async (name) => [name, readLayerSource(new URL(`./${name}`, import.meta.url))]));
|
||||
const sources = Object.fromEntries(files);
|
||||
for (const [name, source] of files) {
|
||||
assert.ok(source.includes('.gevLabelModel ='), `${name} writes the explicit model directly`);
|
||||
assert.ok(source.includes('.gevDisplayPosition ='), `${name} exposes a display-position cache`);
|
||||
}
|
||||
assert.ok(sources['flights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.ok(sources['militaryFlights.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.match(sources['flights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
||||
assert.match(sources['militaryFlights.js'], /gevDisplayPosition\s*=\s*parts\.motion\._trackedDisplayCached/);
|
||||
assert.ok(sources['satellites.js'].includes('gevDisplayPosition = _trackedDisplayCached'));
|
||||
assert.equal(sources['flights.js'].includes('_trackedEntity.label.text'), false);
|
||||
assert.equal(sources['militaryFlights.js'].includes('_trackedEntity.label.text'), false);
|
||||
@@ -260,11 +261,11 @@ test('tracking layers write gevLabelModel and expose only their cached display p
|
||||
|
||||
test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => {
|
||||
const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => (
|
||||
[name, await readFile(new URL(`./${name}`, import.meta.url), 'utf8')]
|
||||
[name, readLayerSource(new URL(`./${name}`, import.meta.url))]
|
||||
)));
|
||||
for (const [name, source] of files) {
|
||||
assert.ok(
|
||||
source.includes('const head = _trackedTrailCached() || _trackedDisplayPosition(_trackedIcao);'),
|
||||
(/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source),
|
||||
`${name} trail head uses the dedicated lower-centre model anchor`,
|
||||
);
|
||||
assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -110,16 +111,16 @@ test('slow clean sprite clicks select, while long presses and orbit nudges canno
|
||||
|
||||
test('civilian and military click handlers apply duration only at the deselect branch', () => {
|
||||
const sources = [
|
||||
readFileSync(new URL('./flights.js', import.meta.url), 'utf8'),
|
||||
readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8'),
|
||||
readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||
];
|
||||
for (const source of sources) {
|
||||
assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
|
||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
sources[0],
|
||||
/_trackedEntity = _viewer\.entities\.add\(\{\s*id:/,
|
||||
/(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/,
|
||||
'civilian tracked entities must retain Cesium-generated GUIDs',
|
||||
);
|
||||
});
|
||||
|
||||
+13
-2506
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { getTrafficTimingDiagnostics } from './traffic.js';
|
||||
|
||||
const SOURCE = readFileSync(new URL('./traffic.js', import.meta.url), 'utf8');
|
||||
const SOURCE = readLayerSource(new URL('./traffic.js', import.meta.url), 'utf8').replace(/parts\.(?:model|timing)\./g, '');
|
||||
|
||||
function functionBody(name) {
|
||||
const declaration = `function ${name}(`;
|
||||
@@ -166,9 +166,8 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
plugins: [{
|
||||
name: 'traffic-timing-test-hooks',
|
||||
transform(code, id) {
|
||||
if (!id.endsWith('/src/data/traffic.js')) return null;
|
||||
return `${code}\nexport const __trafficTimingTestHooks = {\n` +
|
||||
` currentAnchor: () => _trafficTimingCurrentAnchor,\n};\n`;
|
||||
if (!id.endsWith('/src/layers/traffic/index.js')) return null;
|
||||
return code.replace('return Object.assign(', 'return Object.assign({ __trafficTimingTestHooks: { currentAnchor: () => state._trafficTimingCurrentAnchor } },');
|
||||
},
|
||||
}],
|
||||
});
|
||||
@@ -240,7 +239,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
});
|
||||
trafficLayer.enable(viewer);
|
||||
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
|
||||
assert.equal(moveEnd.listenerCount(), 1, 'marksInstalled must represent a live listener');
|
||||
assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check');
|
||||
assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0);
|
||||
assert.equal(performance.getEntriesByName('traffic:stale:measure').length, 0);
|
||||
|
||||
@@ -248,7 +247,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
// and moveEnd arrives only after cameraEventWaitTime (~500 ms).
|
||||
setLongitude(-97.72);
|
||||
changed.raise();
|
||||
const anchorA = traffic.__trafficTimingTestHooks.currentAnchor();
|
||||
const anchorA = traffic.default.__trafficTimingTestHooks.currentAnchor();
|
||||
const anchorMarkA = performance.getEntriesByType('mark').find((entry) => (
|
||||
entry.detail?.segment === 'last-camera-change'
|
||||
&& entry.detail?.interactionId === anchorA.interactionId
|
||||
@@ -262,7 +261,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
traceObjectsCreated: 1,
|
||||
uncorrelatedTracesDropped: 0,
|
||||
});
|
||||
assert.equal(traffic.__trafficTimingTestHooks.currentAnchor(), null);
|
||||
assert.equal(traffic.default.__trafficTimingTestHooks.currentAnchor(), null);
|
||||
const fetchFromA = performance.getEntriesByType('measure').find((entry) => (
|
||||
entry.detail?.segment === 'last-camera-change-to-fetch-start'
|
||||
&& entry.detail?.interactionId === anchorA.interactionId
|
||||
@@ -288,16 +287,16 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
|
||||
// and must count one drop without consuming C's current anchor.
|
||||
setLongitude(-97.70);
|
||||
changed.raise();
|
||||
const anchorB = traffic.__trafficTimingTestHooks.currentAnchor();
|
||||
const anchorB = traffic.default.__trafficTimingTestHooks.currentAnchor();
|
||||
const [timerBId, timerB] = pendingDebounce();
|
||||
setLongitude(-97.68);
|
||||
changed.raise();
|
||||
const anchorC = traffic.__trafficTimingTestHooks.currentAnchor();
|
||||
const anchorC = traffic.default.__trafficTimingTestHooks.currentAnchor();
|
||||
assert.notEqual(anchorC.interactionId, anchorB.interactionId);
|
||||
assert.equal(timeouts.has(timerBId), false, 'the ordinary debounce path must cancel B');
|
||||
await timerB.callback();
|
||||
assert.equal(
|
||||
traffic.__trafficTimingTestHooks.currentAnchor().interactionId,
|
||||
traffic.default.__trafficTimingTestHooks.currentAnchor().interactionId,
|
||||
anchorC.interactionId,
|
||||
'a stale callback must not consume the newer C anchor',
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => {
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.deepEqual(
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
@@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', (
|
||||
|
||||
test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
||||
// is full-screen ABOVE the card and announces itself with nothing. The card
|
||||
@@ -263,14 +264,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
});
|
||||
|
||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
// dismissed itself off the same key. The earlier listener is the only one that
|
||||
// can stop the later one — and only the immediate form does it.
|
||||
const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock"));
|
||||
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
|
||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||
assert.match(claim, /event\.preventDefault\(\);/);
|
||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||
@@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
|
||||
|
||||
test('the scroll fade only appears when the list really overflows', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
// A fade on a card where all five tiles fit promises a sixth mission that does
|
||||
// not exist, which is worse than no affordance at all.
|
||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
||||
@@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', ()
|
||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8');
|
||||
const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
|
||||
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
||||
@@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
||||
});
|
||||
|
||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
|
||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||
// now, so it was opening to offer controls for things already happening —
|
||||
// while competing with the mission card for the one first impression there is.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
function ruleBody(selector) {
|
||||
const start = css.indexOf(selector);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Instance-owned military identity and active-layer classification.
|
||||
* Both aircraft layers share the same application-supplied registry. */
|
||||
export function createMilitaryRegistry({ source, now = Date.now } = {}) {
|
||||
const MIL_POLL_INTERVAL_MS = 60000;
|
||||
|
||||
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
|
||||
const _milIcaos = new Set();
|
||||
/** @type {boolean} True while the dedicated military layer is enabled. */
|
||||
let _militaryLayerActive = false;
|
||||
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
|
||||
const _activeChangeListeners = new Set();
|
||||
/** @type {number} Epoch ms of the last registry refresh (any source). */
|
||||
let _lastRefreshMs = 0;
|
||||
/** @type {boolean} A self-poll fetch is in flight. */
|
||||
let _polling = false;
|
||||
let currentSource = null;
|
||||
let lifetime = null;
|
||||
let releaseSource = null;
|
||||
|
||||
/** Replace the classification source and cancel its outstanding work. */
|
||||
function configureSource(nextSource, { signal } = {}) {
|
||||
if (typeof nextSource?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
releaseSource?.();
|
||||
const controller = new AbortController();
|
||||
lifetime = controller;
|
||||
currentSource = nextSource;
|
||||
_milIcaos.clear();
|
||||
_lastRefreshMs = 0;
|
||||
_polling = false;
|
||||
const release = () => {
|
||||
controller.abort();
|
||||
signal?.removeEventListener('abort', release);
|
||||
if (lifetime !== controller) return;
|
||||
currentSource = null;
|
||||
lifetime = null;
|
||||
releaseSource = null;
|
||||
_milIcaos.clear();
|
||||
_lastRefreshMs = 0;
|
||||
_polling = false;
|
||||
};
|
||||
releaseSource = release;
|
||||
if (signal?.aborted) release();
|
||||
else signal?.addEventListener('abort', release, { once: true });
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the dedicated military layer currently renders these aircraft
|
||||
* (the flights layer should suppress duplicates rather than restyle them).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMilitaryLayerActive() {
|
||||
return _militaryLayerActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the dedicated military layer enabled/disabled. On a TRANSITION
|
||||
* (value actually changed) the registered change listeners fire so the
|
||||
* flights layer can reconcile its duplicates immediately instead of waiting
|
||||
* out its 30 s poll (pre-ship audit M2).
|
||||
* @param {boolean} active - Whether the military layer renders.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setMilitaryLayerActive(active) {
|
||||
const next = !!active;
|
||||
if (next === _militaryLayerActive) return;
|
||||
_militaryLayerActive = next;
|
||||
for (const listener of _activeChangeListeners) {
|
||||
try {
|
||||
listener(next);
|
||||
} catch {
|
||||
// a broken listener must never break the layer toggle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to military-layer active-state transitions (fired only when the
|
||||
* value changes, AFTER the new state is committed).
|
||||
* @param {(active: boolean) => void} listener - Change callback.
|
||||
* @returns {() => void} Unsubscribe function.
|
||||
*/
|
||||
function onMilitaryLayerActiveChange(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
_activeChangeListeners.add(listener);
|
||||
return () => _activeChangeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces/extends the known-military set from a fresh poll.
|
||||
* Adds only — transient dropouts from one poll must not declassify an
|
||||
* aircraft mid-session (the set stays small: a few hundred hexes).
|
||||
* @param {Iterable<string>} icaos - ICAO24 hexes from a /v2/mil response.
|
||||
* @returns {void}
|
||||
*/
|
||||
function registerMilitaryIcaos(icaos) {
|
||||
for (const icao of icaos || []) {
|
||||
const hex = String(icao || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (hex) _milIcaos.add(hex);
|
||||
}
|
||||
_lastRefreshMs = now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft is known military.
|
||||
* @param {string} icao24 - ICAO24 hex (any case).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMilitaryIcao(icao24) {
|
||||
return _milIcaos.has(String(icao24 || '').toLowerCase());
|
||||
}
|
||||
|
||||
/** Refresh known identities while the dedicated layer is disabled.
|
||||
* A failed or cancelled request retains the current set and never swaps sources. */
|
||||
async function refreshMilitaryRegistryIfStale() {
|
||||
if (
|
||||
!currentSource ||
|
||||
!lifetime ||
|
||||
lifetime.signal.aborted ||
|
||||
_militaryLayerActive
|
||||
)
|
||||
return;
|
||||
if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return;
|
||||
const owner = lifetime;
|
||||
const requestSource = currentSource;
|
||||
const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]);
|
||||
_polling = true;
|
||||
try {
|
||||
const ids =
|
||||
typeof requestSource.getIdentities === 'function'
|
||||
? await requestSource.getIdentities({}, { signal })
|
||||
: (await requestSource.getSnapshot({}, { signal })).records.map(
|
||||
(record) => record.id,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (lifetime !== owner) return;
|
||||
if (!Array.isArray(ids))
|
||||
throw new TypeError('Expected aircraft identities');
|
||||
registerMilitaryIcaos(ids);
|
||||
} catch {
|
||||
// Keep the existing classification through source outages.
|
||||
} finally {
|
||||
if (lifetime === owner) _polling = false;
|
||||
}
|
||||
}
|
||||
function dispose() {
|
||||
releaseSource?.();
|
||||
_milIcaos.clear();
|
||||
_lastRefreshMs = 0;
|
||||
_activeChangeListeners.clear();
|
||||
_militaryLayerActive = false;
|
||||
}
|
||||
if (source) configureSource(source);
|
||||
return {
|
||||
configureSource,
|
||||
dispose,
|
||||
isMilitaryLayerActive,
|
||||
setMilitaryLayerActive,
|
||||
onMilitaryLayerActiveChange,
|
||||
registerMilitaryIcaos,
|
||||
isMilitaryIcao,
|
||||
refreshMilitaryRegistryIfStale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMilitaryRegistry } from './classification.js';
|
||||
|
||||
test('classification instances have independent identities, listeners and active states', () => {
|
||||
let requests = 0;
|
||||
const source = {
|
||||
getSnapshot() {
|
||||
requests++;
|
||||
},
|
||||
};
|
||||
const first = createMilitaryRegistry({ source });
|
||||
const second = createMilitaryRegistry({ source });
|
||||
let firstEvents = 0;
|
||||
let secondEvents = 0;
|
||||
first.onMilitaryLayerActiveChange(() => firstEvents++);
|
||||
second.onMilitaryLayerActiveChange(() => secondEvents++);
|
||||
first.registerMilitaryIcaos([' ABC123 ']);
|
||||
first.setMilitaryLayerActive(true);
|
||||
assert.equal(first.isMilitaryIcao('abc123'), true);
|
||||
assert.equal(second.isMilitaryIcao('abc123'), false);
|
||||
assert.equal(firstEvents, 1);
|
||||
assert.equal(secondEvents, 0);
|
||||
assert.equal(second.isMilitaryLayerActive(), false);
|
||||
assert.equal(requests, 0);
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
});
|
||||
|
||||
test('classification uses normalized identities and respects its polling interval and active layer', async () => {
|
||||
let time = 120000;
|
||||
let calls = 0;
|
||||
const registry = createMilitaryRegistry({
|
||||
now: () => time,
|
||||
source: {
|
||||
async getSnapshot() {
|
||||
calls++;
|
||||
return { records: [{ id: 'abc123' }] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
time += 60000;
|
||||
registry.setMilitaryLayerActive(true);
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(calls, 1);
|
||||
registry.setMilitaryLayerActive(false);
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(calls, 2);
|
||||
registry.dispose();
|
||||
});
|
||||
|
||||
test('source replacement rejects an old response and disposal cancels the current request', async () => {
|
||||
let resolveOld;
|
||||
let resolveNew;
|
||||
let oldSignal;
|
||||
let newSignal;
|
||||
const registry = createMilitaryRegistry({
|
||||
source: {
|
||||
getSnapshot(query, { signal }) {
|
||||
oldSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const old = registry.refreshMilitaryRegistryIfStale();
|
||||
const application = new AbortController();
|
||||
registry.configureSource(
|
||||
{
|
||||
getSnapshot(query, { signal }) {
|
||||
newSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveNew = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
{ signal: application.signal },
|
||||
);
|
||||
const current = registry.refreshMilitaryRegistryIfStale();
|
||||
resolveOld({ records: [{ id: 'abc123' }] });
|
||||
await old;
|
||||
application.abort();
|
||||
resolveNew({ records: [{ id: 'def456' }] });
|
||||
await current;
|
||||
assert.equal(oldSignal.aborted, true);
|
||||
assert.equal(newSignal.aborted, true);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
assert.equal(registry.isMilitaryIcao('def456'), false);
|
||||
registry.dispose();
|
||||
});
|
||||
|
||||
test('a failed classification source preserves known identities and makes no fallback request', async () => {
|
||||
let time = 120000;
|
||||
let calls = 0;
|
||||
const registry = createMilitaryRegistry({
|
||||
now: () => time,
|
||||
source: {
|
||||
async getSnapshot() {
|
||||
calls++;
|
||||
throw new Error('fixture source unavailable');
|
||||
},
|
||||
},
|
||||
});
|
||||
registry.registerMilitaryIcaos(['abc123']);
|
||||
time += 60000;
|
||||
await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale());
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
registry.dispose();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
});
|
||||
|
||||
test('disposing a registry fed directly by its layer also clears classification', () => {
|
||||
const registry = createMilitaryRegistry();
|
||||
registry.registerMilitaryIcaos(['abc123']);
|
||||
registry.dispose();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||
});
|
||||
|
||||
test('classification prefers an explicit identity capability over positioned observations', async () => {
|
||||
const registry = createMilitaryRegistry({
|
||||
source: {
|
||||
getSnapshot() {
|
||||
assert.fail(
|
||||
'position snapshots must not replace an available identity capability',
|
||||
);
|
||||
},
|
||||
async getIdentities() {
|
||||
return ['abc123'];
|
||||
},
|
||||
},
|
||||
});
|
||||
await registry.refreshMilitaryRegistryIfStale();
|
||||
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||
registry.dispose();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Common aircraft calculations and explicitly constructed classification. */
|
||||
export { createMilitaryRegistry } from './classification.js';
|
||||
export * from '../../data/aircraftClass.js';
|
||||
export * from '../../data/aircraftIcons.js';
|
||||
export * from '../../data/aircraftMeta.js';
|
||||
export * from '../../data/aircraftNearbyPolicy.js';
|
||||
export * from '../../data/cockpitAirLod.js';
|
||||
export * from '../../data/cockpitContactDot.js';
|
||||
export * from '../../data/contactMatch.js';
|
||||
export * from '../../data/iconOrientation.js';
|
||||
export * from '../../data/layerState.js';
|
||||
export * from '../../data/modelVisualAnchor.js';
|
||||
export * from '../../data/motionModel.js';
|
||||
export * from '../../data/renderAltitude.js';
|
||||
export * from '../../data/routePlausible.js';
|
||||
export * from '../../data/trackedModelRegime.js';
|
||||
export * from '../../data/trackingClickGesture.js';
|
||||
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
const militaryFlightsLayer = services.military;
|
||||
const aisLiveVesselsLayer = services.vessels;
|
||||
|
||||
const methods = {
|
||||
id: 'military-awareness',
|
||||
|
||||
name: 'Global Context',
|
||||
|
||||
icon: '◎',
|
||||
|
||||
source: 'Open-source proximity context',
|
||||
|
||||
// Context is entered from its dedicated right rail, not as a raw layer.
|
||||
showInTogglePanel: false,
|
||||
|
||||
updateInterval: 0,
|
||||
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
if (typeof params.passive !== 'boolean') return;
|
||||
const wasPassive = layerState.passive;
|
||||
layerState.passive = params.passive;
|
||||
if (layerState.enabled && wasPassive && !layerState.passive)
|
||||
parts.dependencies.activateOperationalContext();
|
||||
},
|
||||
|
||||
/** @returns {{ passive: boolean }} Current runtime parameters. */
|
||||
getParams() {
|
||||
return { passive: layerState.passive };
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState.results ? 1 : 0,
|
||||
lastUpdate: layerState.results?.evaluatedAt || null,
|
||||
stale: false,
|
||||
error: null,
|
||||
status: layerState.enabled ? 'ready' : 'idle',
|
||||
};
|
||||
},
|
||||
|
||||
/** Return the latest read-only context result for compact HUD consumers. */
|
||||
getContextSnapshot() {
|
||||
if (!layerState.enabled || !layerState.subject) return null;
|
||||
if (!layerState.results) {
|
||||
return parts.model.buildAwarenessContextSnapshot(
|
||||
{
|
||||
subject: { ...layerState.subject },
|
||||
evaluatedAt: null,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
cohorts: [],
|
||||
},
|
||||
parts.model.navigationState(),
|
||||
{
|
||||
subjectPresent: !layerState.subjectMissing,
|
||||
},
|
||||
);
|
||||
}
|
||||
return parts.model.buildAwarenessContextSnapshot(
|
||||
layerState.results,
|
||||
parts.model.navigationState(),
|
||||
{
|
||||
subjectPresent: !layerState.subjectMissing,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Release Contact-owned camera tracking without discarding the selected
|
||||
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
|
||||
* can explicitly return to the same contact, while delayed activation work
|
||||
* cannot silently reclaim the camera after the reset.
|
||||
* @returns {boolean} Whether a Contact subject remains selected.
|
||||
*/
|
||||
releaseCameraOwnership({
|
||||
preserveVesselSelection = false,
|
||||
origin = 'programmatic',
|
||||
} = {}) {
|
||||
++layerState.activationId;
|
||||
layerState.autoFocusAttempted = true;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
|
||||
const preservedSelectionKey =
|
||||
parts.subject.subjectKey(layerState.subject) || 'camera-release';
|
||||
layerState.pendingSelectionKey = preservedSelectionKey;
|
||||
try {
|
||||
flightsLayer.stopTracking?.({ origin });
|
||||
militaryFlightsLayer.stopTracking?.({ origin });
|
||||
if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.();
|
||||
} finally {
|
||||
if (layerState.pendingSelectionKey === preservedSelectionKey) {
|
||||
layerState.pendingSelectionKey = null;
|
||||
}
|
||||
}
|
||||
return Boolean(layerState.subject);
|
||||
},
|
||||
|
||||
navigatePrevious(options = {}) {
|
||||
return parts.history.navigateHistory(-1, options);
|
||||
},
|
||||
|
||||
focusCurrent(options = {}) {
|
||||
return parts.focus.focusCurrentSubject(options);
|
||||
},
|
||||
|
||||
navigateNext(options = {}) {
|
||||
return parts.history.navigateHistory(1, options);
|
||||
},
|
||||
|
||||
/** Select a context target through its owning layer's established tracker. */
|
||||
focusTarget(layerId, id, options = {}) {
|
||||
return parts.focus.requestFocus(layerId, id, false, options);
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js';
|
||||
|
||||
export function createDependencies({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
function refreshAfterDeferredDependency(activationId) {
|
||||
if (
|
||||
!layerState.enabled ||
|
||||
layerState.passive ||
|
||||
activationId !== layerState.activationId
|
||||
)
|
||||
return;
|
||||
if (layerState.subject) {
|
||||
parts.subject.refreshSelectedSubject(true);
|
||||
return;
|
||||
}
|
||||
if (layerState.autoFocusRetryPending) {
|
||||
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDependencies(activationId = layerState.activationId) {
|
||||
const aircraftPending = [];
|
||||
for (const layerId of DEPENDENCIES) {
|
||||
if (!layerState.enabled) return;
|
||||
if (!layerState.dataManager) continue;
|
||||
const alreadyEnabled =
|
||||
layerState.dataManager.isEffectivelyEnabled?.(layerId) ??
|
||||
layerState.dataManager.isEnabled(layerId);
|
||||
if (alreadyEnabled) continue;
|
||||
layerState.ownedDependencies.add(layerId);
|
||||
// Each layer serializes its own lifecycle internally. Start them together
|
||||
// so a slow or unavailable live feed cannot prevent the remaining context
|
||||
// sources from turning on.
|
||||
let pending;
|
||||
try {
|
||||
// Register the ON intent synchronously. If Contacts is turned OFF in the
|
||||
// same turn, DataLayerManager then sees OFF as the newest intent and a
|
||||
// stale deferred ON cannot resurrect a released dependency.
|
||||
pending = Promise.resolve(
|
||||
layerState.dataManager.setEnabled(layerId, true),
|
||||
);
|
||||
} catch (error) {
|
||||
pending = Promise.reject(error);
|
||||
}
|
||||
if (AIRCRAFT_DEPENDENCIES.includes(layerId)) {
|
||||
aircraftPending.push(pending);
|
||||
} else {
|
||||
// Vessels and mapped installations enrich an already usable aircraft
|
||||
// context. Their settlement must never hold the Contacts transaction or
|
||||
// Cockpit gate open, and the activation token prevents a late result
|
||||
// from repainting or refocusing a newer/closed session.
|
||||
pending.then(
|
||||
() => refreshAfterDeferredDependency(activationId),
|
||||
() => refreshAfterDeferredDependency(activationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(aircraftPending);
|
||||
}
|
||||
|
||||
function releaseOwnedDependencies(releaseActivationId) {
|
||||
const owned = [...layerState.ownedDependencies];
|
||||
layerState.ownedDependencies.clear();
|
||||
if (!layerState.dataManager || !owned.length) return Promise.resolve();
|
||||
return Promise.allSettled(
|
||||
owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)),
|
||||
).then(() => {
|
||||
if (
|
||||
layerState.enabled &&
|
||||
layerState.activationId !== releaseActivationId
|
||||
) {
|
||||
return enableDependencies(layerState.activationId);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
const initialTrackedSubject = parts.subject.currentTrackedFlightSubject();
|
||||
if (initialTrackedSubject)
|
||||
parts.subject.selectSubject(initialTrackedSubject);
|
||||
return enableDependencies(activationId)
|
||||
.then(() => {
|
||||
if (
|
||||
!layerState.enabled ||
|
||||
layerState.passive ||
|
||||
activationId !== layerState.activationId
|
||||
)
|
||||
return;
|
||||
const trackedSubject = parts.subject.currentTrackedFlightSubject();
|
||||
if (trackedSubject) {
|
||||
parts.subject.selectSubject(trackedSubject);
|
||||
return;
|
||||
}
|
||||
if (layerState.subject) {
|
||||
parts.subject.selectSubject(layerState.subject);
|
||||
return;
|
||||
}
|
||||
if (layerState.autoFocusAttempted) return;
|
||||
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
|
||||
})
|
||||
.catch((error) =>
|
||||
console.warn('[Global Context] dependency enable failed', error),
|
||||
);
|
||||
}
|
||||
return {
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
|
||||
|
||||
export function createFocus({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
const militaryFlightsLayer = services.military;
|
||||
const { announceNavigationAuthority } = services.navigation;
|
||||
const militaryInstallationsLayer = services.installations;
|
||||
const aisLiveVesselsLayer = services.vessels;
|
||||
|
||||
/** Focus a nearby example through the source layer that owns its selection. */
|
||||
|
||||
function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) {
|
||||
if (!layerId || !id) return false;
|
||||
if (layerId === 'flights') {
|
||||
return (
|
||||
flightsLayer.refocusTrackedById?.(id, { origin }) ||
|
||||
flightsLayer.trackById(id, { origin })
|
||||
);
|
||||
}
|
||||
if (layerId === 'military') {
|
||||
return (
|
||||
militaryFlightsLayer.refocusTrackedById?.(id, { origin }) ||
|
||||
militaryFlightsLayer.trackById(id, { origin })
|
||||
);
|
||||
}
|
||||
if (layerId === 'military-installations') {
|
||||
if (!parts.model.contextTargetFlyToAllowed(layerId))
|
||||
return selectKnownContextTarget(layerId, id);
|
||||
// focusById flies the camera; it never assigns a tracked entity, so the
|
||||
// stamp has to be announced here.
|
||||
announceNavigationAuthority('context-installation-focus');
|
||||
return militaryInstallationsLayer.focusById(id);
|
||||
}
|
||||
if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id))
|
||||
return false;
|
||||
|
||||
const vessel = aisLiveVesselsLayer
|
||||
.getAllPositions(12000)
|
||||
.find((item) => String(item.id) === String(id));
|
||||
if (
|
||||
!parts.model.contextTargetFlyToAllowed(layerId) ||
|
||||
!vessel?.position ||
|
||||
!layerState.viewer
|
||||
)
|
||||
return true;
|
||||
announceNavigationAuthority('context-vessel-focus');
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.4 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectKnownContextTarget(layerId, id) {
|
||||
const key = `${layerId}:${id}`;
|
||||
const known = [
|
||||
layerState.subject,
|
||||
...layerState.navigationHistory,
|
||||
...(layerState.results?.cohorts || []).flatMap((cohort) =>
|
||||
cohort.id === layerId
|
||||
? cohort.summary.navigationNearest || cohort.summary.nearest || []
|
||||
: [],
|
||||
),
|
||||
].find(
|
||||
(item) =>
|
||||
item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key,
|
||||
);
|
||||
if (!known?.position) return false;
|
||||
if (parts.model.contextTargetFlyToAllowed(layerId))
|
||||
parts.model.releaseAircraftTracking();
|
||||
parts.subject.selectSubject({
|
||||
...known,
|
||||
layerId,
|
||||
id: String(known.id || known.mmsi),
|
||||
label: known.label || known.name || known.callsign || String(id),
|
||||
position: Cesium.Cartesian3.clone(known.position),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function requestFocus(
|
||||
layerId,
|
||||
id,
|
||||
preserveHistory = false,
|
||||
{ origin = 'programmatic' } = {},
|
||||
) {
|
||||
const key = `${layerId}:${id}`;
|
||||
layerState.pendingSelectionKey = key;
|
||||
if (preserveHistory) layerState.suppressedHistoryKey = key;
|
||||
const focused = focusNearbyTarget(layerId, id, { origin });
|
||||
layerState.pendingSelectionKey = null;
|
||||
layerState.suppressedHistoryKey = null;
|
||||
return focused;
|
||||
}
|
||||
|
||||
function focusSubject(subject, preserveHistory = false, options = {}) {
|
||||
if (!subject) return false;
|
||||
return requestFocus(subject.layerId, subject.id, preserveHistory, options);
|
||||
}
|
||||
|
||||
function focusCurrentSubject(options = {}) {
|
||||
return focusSubject(layerState.subject, true, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the observed candidate closest to the current view. This is intentionally
|
||||
* a navigation preference, not a risk, capability, or affiliation calculation.
|
||||
* @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates.
|
||||
* @returns {Object|null} The best currently observable candidate.
|
||||
*/
|
||||
|
||||
function closestToCurrentView(candidates) {
|
||||
if (!Array.isArray(candidates) || !candidates.length) return null;
|
||||
const cameraPosition = layerState.viewer?.camera?.positionWC;
|
||||
if (!cameraPosition)
|
||||
return candidates.find((candidate) => candidate?.position) || null;
|
||||
|
||||
let closest = null;
|
||||
let closestDistance = Infinity;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate?.position) continue;
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
cameraPosition,
|
||||
candidate.position,
|
||||
);
|
||||
if (Number.isFinite(distance) && distance < closestDistance) {
|
||||
closest = candidate;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a single observed, context-priority target after activation. Civilian
|
||||
* and military aircraft compete in one nearest-to-view pool; military is
|
||||
* concatenated first so exact distance ties resolve to military under the
|
||||
* strict comparison. An AIS vessel is only a fallback and is never inferred
|
||||
* to be military. There is deliberately no distance cap.
|
||||
* @returns {boolean} Whether a target was selected and framed.
|
||||
*/
|
||||
|
||||
function focusAttentionTarget() {
|
||||
if (
|
||||
!layerState.enabled ||
|
||||
layerState.subject ||
|
||||
!layerState.viewer ||
|
||||
layerState.autoFocusAttempted
|
||||
)
|
||||
return false;
|
||||
|
||||
const nearestFlight = closestToCurrentView([
|
||||
...militaryFlightsLayer
|
||||
.getAllPositions(800)
|
||||
.map((item) => ({ ...item, layerId: 'military' })),
|
||||
...flightsLayer
|
||||
.getAllPositions(1000)
|
||||
.map((item) => ({ ...item, layerId: 'flights' })),
|
||||
]);
|
||||
if (nearestFlight) {
|
||||
const layer =
|
||||
nearestFlight.layerId === 'military'
|
||||
? militaryFlightsLayer
|
||||
: flightsLayer;
|
||||
if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) {
|
||||
layerState.autoFocusAttempted = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const vessel = closestToCurrentView(
|
||||
aisLiveVesselsLayer.getAllPositions(12000),
|
||||
);
|
||||
if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false;
|
||||
|
||||
layerState.autoFocusAttempted = true;
|
||||
if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true;
|
||||
|
||||
announceNavigationAuthority('context-vessel-autofocus', {
|
||||
cancelPendingSelection: false,
|
||||
});
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.6 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
focusNearbyTarget,
|
||||
selectKnownContextTarget,
|
||||
requestFocus,
|
||||
focusSubject,
|
||||
focusCurrentSubject,
|
||||
closestToCurrentView,
|
||||
focusAttentionTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
export function createHistory({ state: layerState, services, parts, source }) {
|
||||
const militaryFlightsLayer = services.military;
|
||||
const flightsLayer = services.flights;
|
||||
|
||||
/**
|
||||
* Walk navigation history for the next entry that can still be focused.
|
||||
*
|
||||
* A history entry only holds a SNAPSHOT of a contact; the live contact behind
|
||||
* it can be evicted from its layer at any time (the cull that drives
|
||||
* CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the
|
||||
* only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead
|
||||
* entry strands the operator there for the rest of the session, because the
|
||||
* index never advances past it.
|
||||
*
|
||||
* `state.navigationIndex` moves only on a successful focus: a caller that
|
||||
* falls through to the live cohort must still splice forward history from the
|
||||
* position the operator actually occupies.
|
||||
* @param {number} direction Walk direction (negative = previous).
|
||||
* @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters.
|
||||
* @returns {boolean} Whether a history entry was focused.
|
||||
*/
|
||||
|
||||
function focusCompatibleHistory(
|
||||
direction,
|
||||
{ targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' },
|
||||
) {
|
||||
let cursor = layerState.navigationIndex;
|
||||
for (;;) {
|
||||
const historyIndex = findCompatibleHistoryIndex(
|
||||
layerState.navigationHistory,
|
||||
cursor,
|
||||
direction,
|
||||
{
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
aircraftOnly,
|
||||
resolveItem: historySubjectItem,
|
||||
},
|
||||
);
|
||||
if (historyIndex === -1) return false;
|
||||
if (
|
||||
parts.focus.focusSubject(
|
||||
layerState.navigationHistory[historyIndex],
|
||||
true,
|
||||
{ origin },
|
||||
)
|
||||
) {
|
||||
layerState.navigationIndex = historyIndex;
|
||||
return true;
|
||||
}
|
||||
// Contact evicted since it entered history — step over it. The scan is
|
||||
// strictly monotonic, so this terminates at the end of history.
|
||||
cursor = historyIndex;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateHistory(
|
||||
direction,
|
||||
{
|
||||
targetLayer = null,
|
||||
aircraftClass = null,
|
||||
aircraftOnly = false,
|
||||
origin = 'programmatic',
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
focusCompatibleHistory(direction, {
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
aircraftOnly,
|
||||
origin,
|
||||
})
|
||||
)
|
||||
return true;
|
||||
|
||||
if (direction < 0) return false;
|
||||
|
||||
let targets = parts.navigation.selectNavigationTargets(
|
||||
layerState.results?.cohorts,
|
||||
layerState.subject,
|
||||
[...layerState.navigationVisited],
|
||||
{ targetLayer, aircraftClass, aircraftOnly },
|
||||
);
|
||||
const targetHistoryFullyVisited =
|
||||
targets.length > 0 && targets.every((target) => target.visited);
|
||||
const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown();
|
||||
if (
|
||||
targetHistoryFullyVisited &&
|
||||
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
|
||||
!subjectCohortUnknown
|
||||
) {
|
||||
const expanded = parts.navigation.findExpandedFlightTarget({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
});
|
||||
if (expanded) {
|
||||
return parts.focus.requestFocus(
|
||||
expanded.candidate.layerId,
|
||||
expanded.candidate.id,
|
||||
false,
|
||||
{ origin },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (targetHistoryFullyVisited) {
|
||||
parts.navigation.resetNavigationVisitedCycle();
|
||||
targets = parts.navigation.selectNavigationTargets(
|
||||
layerState.results?.cohorts,
|
||||
layerState.subject,
|
||||
[...layerState.navigationVisited],
|
||||
{ targetLayer, aircraftClass, aircraftOnly },
|
||||
);
|
||||
}
|
||||
const hasNearbyFlight = targets.some((target) =>
|
||||
parts.navigation.isFlightLayer(target.layerId),
|
||||
);
|
||||
if (
|
||||
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
|
||||
!hasNearbyFlight &&
|
||||
!subjectCohortUnknown
|
||||
) {
|
||||
const expanded = parts.navigation.findExpandedFlightTarget({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
});
|
||||
if (expanded) {
|
||||
return parts.focus.requestFocus(
|
||||
expanded.candidate.layerId,
|
||||
expanded.candidate.id,
|
||||
false,
|
||||
{ origin },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (parts.subject.subjectCohortFeedUnknown()) return false;
|
||||
const candidate = targets[0];
|
||||
if (!candidate) return false;
|
||||
return parts.focus.requestFocus(candidate.layerId, candidate.id, false, {
|
||||
origin,
|
||||
});
|
||||
}
|
||||
|
||||
function historySubjectItem(subject) {
|
||||
if (!subject) return null;
|
||||
const cohort = layerState.results?.cohorts?.find(
|
||||
(item) => item.id === subject.layerId,
|
||||
);
|
||||
const items =
|
||||
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
|
||||
return (
|
||||
items.find(
|
||||
(item) =>
|
||||
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
||||
) || subject
|
||||
);
|
||||
}
|
||||
|
||||
/** Retain filter metadata when a production subject enters navigation history. */
|
||||
|
||||
function historySubjectSnapshot(subject, sourceItem = null) {
|
||||
const aircraftClass =
|
||||
sourceItem?.aircraftClass ||
|
||||
sourceItem?.klass ||
|
||||
sourceItem?.type ||
|
||||
subject?.aircraftClass ||
|
||||
null;
|
||||
return aircraftClass ? { ...subject, aircraftClass } : { ...subject };
|
||||
}
|
||||
|
||||
function historySourceItem(subject) {
|
||||
if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position)
|
||||
return null;
|
||||
// The current sweep already holds this contact's record. Reusing it keeps
|
||||
// selection O(1) against the cohort instead of paying a fresh full-layer
|
||||
// proximity scan per selection — the burst cost when NEXT walks a cohort.
|
||||
const cohort = layerState.results?.cohorts?.find(
|
||||
(item) => item.id === subject.layerId,
|
||||
);
|
||||
const items =
|
||||
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
|
||||
const fromSweep = items.find(
|
||||
(item) => String(item?.icao24 || item?.id) === String(subject.id),
|
||||
);
|
||||
if (fromSweep) return fromSweep;
|
||||
const layer =
|
||||
subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer;
|
||||
return (
|
||||
layer
|
||||
.getNearby(subject.position, 1000, 25, { includeHidden: true })
|
||||
.find(
|
||||
(item) => String(item?.icao24 || item?.id) === String(subject.id),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** Find the next history entry compatible with requested navigation filters. */
|
||||
|
||||
function findCompatibleHistoryIndex(
|
||||
history,
|
||||
startIndex,
|
||||
direction,
|
||||
{
|
||||
targetLayer = null,
|
||||
aircraftClass = null,
|
||||
aircraftOnly = false,
|
||||
resolveItem = (subject) => subject,
|
||||
} = {},
|
||||
) {
|
||||
const step = direction < 0 ? -1 : 1;
|
||||
for (
|
||||
let index = startIndex + step;
|
||||
index >= 0 && index < history.length;
|
||||
index += step
|
||||
) {
|
||||
const subject = history[index];
|
||||
if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId))
|
||||
continue;
|
||||
if (targetLayer && subject?.layerId !== targetLayer) continue;
|
||||
if (
|
||||
aircraftClass &&
|
||||
!parts.navigation.aircraftClassMatchesFilter(
|
||||
resolveItem(subject),
|
||||
aircraftClass,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return {
|
||||
focusCompatibleHistory,
|
||||
navigateHistory,
|
||||
historySubjectItem,
|
||||
historySubjectSnapshot,
|
||||
historySourceItem,
|
||||
findCompatibleHistoryIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createModel } from './model.js';
|
||||
import { createSubject } from './subject.js';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createNavigation } from './navigation.js';
|
||||
import { createFocus } from './focus.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createHistory } from './history.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createDependencies } from './dependencies.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createAwarenessLayer({ services, source }) {
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.subject = createSubject(context);
|
||||
parts.panel = createPanel(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.navigation = createNavigation(context);
|
||||
parts.focus = createFocus(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.history = createHistory(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.dependencies = createDependencies(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed,
|
||||
awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject,
|
||||
awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs,
|
||||
awarenessRefreshDecision: parts.model.awarenessRefreshDecision,
|
||||
awarenessClearIsEviction: parts.model.awarenessClearIsEviction,
|
||||
awarenessRefreshRequired: parts.model.awarenessRefreshRequired,
|
||||
summarizeInstallationViewport:
|
||||
parts.queries.summarizeInstallationViewport,
|
||||
contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot,
|
||||
buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot,
|
||||
collectAircraftProximityWindow:
|
||||
parts.queries.collectAircraftProximityWindow,
|
||||
_getAwarenessNavigationStateForTest:
|
||||
parts.testing._getAwarenessNavigationStateForTest,
|
||||
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
|
||||
historySubjectSnapshot: parts.history.historySubjectSnapshot,
|
||||
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
|
||||
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
|
||||
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
|
||||
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
|
||||
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
|
||||
awarenessNeedsContinuousRender:
|
||||
parts.model.awarenessNeedsContinuousRender,
|
||||
},
|
||||
);
|
||||
}
|
||||
export { AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const methods = {
|
||||
update() {
|
||||
parts.subject.refreshSelectedSubject();
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Quantized camera-pose signature, or '' when the camera cannot report a full
|
||||
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
|
||||
* up can never be mistaken for continuous movement.
|
||||
* @param {Cesium.Camera|null|undefined} camera
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
function cameraMotionSignature(camera) {
|
||||
if (!camera?.positionWC || !Number.isFinite(camera.heading)) return '';
|
||||
return cameraPoseSignature(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take or drop the continuous-render hold to match the current need. Idempotent
|
||||
* (the governor is identity-keyed), and self-healing: the release decision is
|
||||
* made on a frame the hold itself guaranteed, so a hold can never strand the
|
||||
* scene in continuous mode.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function syncAwarenessRenderHold() {
|
||||
if (layerState.enabled && parts.model.awarenessNeedsContinuousRender())
|
||||
holdContinuousRender('military-awareness');
|
||||
else releaseContinuousRender('military-awareness');
|
||||
}
|
||||
|
||||
function attachRuntimeListeners() {
|
||||
if (layerState.runtimeListenersAttached || !layerState.viewer) return;
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
layerState.subjectListener,
|
||||
);
|
||||
window.addEventListener('gev:entity-selected', layerState.contextListener);
|
||||
window.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
layerState.clearListener,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
layerState.preRenderRemover =
|
||||
layerState.viewer.scene.preRender.addEventListener(() => {
|
||||
if (!layerState.enabled) return;
|
||||
const now = Date.now();
|
||||
// "Did the camera move" uses the SAME quantized pose signature the fleet
|
||||
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
|
||||
// camera.changed, whose granularity is globally degraded by other layers
|
||||
// mutating camera.percentageChanged.
|
||||
const poseSig = cameraMotionSignature(layerState.viewer.camera);
|
||||
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||
layerState.lastCameraPoseSig = poseSig;
|
||||
layerState.lastCameraPoseChangeMs = now;
|
||||
}
|
||||
const decision = parts.model.awarenessRefreshDecision({
|
||||
nowMs: now,
|
||||
lastRefreshMs: layerState.lastSubjectRefreshMs,
|
||||
lastPoseChangeMs: layerState.lastCameraPoseChangeMs,
|
||||
wasMoving: layerState.cameraMoving,
|
||||
});
|
||||
layerState.cameraMoving = decision.moving;
|
||||
syncAwarenessRenderHold();
|
||||
if (!decision.refresh) return;
|
||||
layerState.lastSubjectRefreshMs = now;
|
||||
if (
|
||||
layerState.subject &&
|
||||
['flights', 'military', 'ais-live-vessels'].includes(
|
||||
layerState.subject.layerId,
|
||||
)
|
||||
) {
|
||||
parts.subject.refreshSelectedSubject();
|
||||
} else if (!layerState.passive && layerState.autoFocusRetryPending) {
|
||||
layerState.autoFocusRetryPending = false;
|
||||
parts.focus.focusAttentionTarget();
|
||||
} else {
|
||||
parts.rendering.scheduleDirectionOverlayUpdate();
|
||||
}
|
||||
});
|
||||
layerState.runtimeListenersAttached = true;
|
||||
}
|
||||
|
||||
function detachRuntimeListeners() {
|
||||
if (layerState.runtimeListenersAttached) {
|
||||
window.removeEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
layerState.subjectListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
'gev:entity-selected',
|
||||
layerState.contextListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
layerState.clearListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
}
|
||||
layerState.preRenderRemover?.();
|
||||
layerState.preRenderRemover = null;
|
||||
layerState.lastCameraPoseSig = '';
|
||||
layerState.lastCameraPoseChangeMs = 0;
|
||||
layerState.cameraMoving = false;
|
||||
// No frames will arrive to run the per-frame release once the listener is
|
||||
// gone, so drop the hold here.
|
||||
releaseContinuousRender('military-awareness');
|
||||
parts.rendering.cancelDirectionOverlayUpdate();
|
||||
layerState.runtimeListenersAttached = false;
|
||||
}
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
detachRuntimeListeners();
|
||||
layerState.viewer = viewer;
|
||||
layerState.subjectListener = (event) => {
|
||||
if (!layerState.enabled) return;
|
||||
parts.subject.selectSubject(event.detail);
|
||||
};
|
||||
layerState.contextListener = (event) => {
|
||||
if (!layerState.enabled) return;
|
||||
const subject = parts.subject.subjectFromContext(event.detail);
|
||||
if (!subject) return;
|
||||
if (parts.model.contextTargetFlyToAllowed(subject.layerId))
|
||||
parts.model.releaseAircraftTracking();
|
||||
parts.subject.selectSubject(subject);
|
||||
};
|
||||
layerState.clearListener = (event) => {
|
||||
if (!layerState.enabled || layerState.pendingSelectionKey) return;
|
||||
if (
|
||||
!parts.subject.awarenessClearMatchesSubject(
|
||||
layerState.subject,
|
||||
event.detail,
|
||||
)
|
||||
)
|
||||
return;
|
||||
// An eviction keeps the subject so the readout can hold last-known
|
||||
// values; only a deliberate clear tears the selection down.
|
||||
if (parts.model.awarenessClearIsEviction(event.detail)) {
|
||||
parts.subject.markSubjectEvicted();
|
||||
return;
|
||||
}
|
||||
parts.subject.clearAwarenessSubject();
|
||||
};
|
||||
layerState.subjectClearListener = (event) => {
|
||||
if (!layerState.enabled) return;
|
||||
const cleared = event.detail;
|
||||
if (layerState.pendingSelectionKey) return;
|
||||
if (
|
||||
parts.subject.awarenessClearMatchesSubject(
|
||||
layerState.subject,
|
||||
cleared,
|
||||
) &&
|
||||
String(layerState.subject?.id) === String(cleared?.id)
|
||||
) {
|
||||
if (parts.model.awarenessClearIsEviction(cleared)) {
|
||||
// Deliberately does NOT set autoFocusAttempted: the subject survives,
|
||||
// so the entry fallback has nothing to replace and the settlement
|
||||
// guard below stays scoped to real deselects.
|
||||
parts.subject.markSubjectEvicted();
|
||||
return;
|
||||
}
|
||||
// A deliberate clear during dependency settlement must win over the
|
||||
// entry fallback; otherwise Contacts can silently select a replacement.
|
||||
layerState.autoFocusAttempted = true;
|
||||
parts.subject.clearAwarenessSubject();
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
enable() {
|
||||
layerState.enabled = true;
|
||||
// NO unconditional continuous-render hold here. Contacts animates per frame
|
||||
// only while the VIEW is moving (the direction arrows are screen-projected);
|
||||
// parked, it is a throttled readout with nothing to animate. The hold is
|
||||
// taken and released per frame by syncAwarenessRenderHold().
|
||||
attachRuntimeListeners();
|
||||
parts.panel.startAwarenessPageRotation();
|
||||
layerState.autoFocusAttempted = false;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
parts.panel.ensurePanel();
|
||||
parts.panel.hidePanel();
|
||||
if (!layerState.passive)
|
||||
return parts.dependencies.activateOperationalContext();
|
||||
return true;
|
||||
},
|
||||
|
||||
disable() {
|
||||
layerState.enabled = false;
|
||||
releaseContinuousRender('military-awareness');
|
||||
detachRuntimeListeners();
|
||||
parts.panel.stopAwarenessPageRotation();
|
||||
layerState.cohortPages.clear();
|
||||
const releaseActivationId = ++layerState.activationId;
|
||||
layerState.autoFocusAttempted = false;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
layerState.subject = null;
|
||||
layerState.results = null;
|
||||
layerState.lastSubjectRefreshMs = 0;
|
||||
layerState.lastEvaluatedPosition = null;
|
||||
layerState.sourceRevision = '';
|
||||
layerState.navigationHistory = [];
|
||||
layerState.navigationVisited.clear();
|
||||
layerState.navigationIndex = -1;
|
||||
layerState.suppressedHistoryKey = null;
|
||||
layerState.pendingSelectionKey = null;
|
||||
parts.rendering.clearVisual();
|
||||
parts.panel.hidePanel();
|
||||
// The manager must not publish this coordinator as settled OFF while its
|
||||
// owned dependency releases are still issuing newer absolute intents.
|
||||
// Context mode handoffs restore the pre-entry snapshot only after this
|
||||
// promise resolves, preventing those releases from superseding restore.
|
||||
layerState.passive = true;
|
||||
return parts.dependencies.releaseOwnedDependencies(releaseActivationId);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.disable();
|
||||
detachRuntimeListeners();
|
||||
layerState.panel?.removeEventListener(
|
||||
'click',
|
||||
layerState.panelClickListener,
|
||||
);
|
||||
layerState.panelClickListener = null;
|
||||
if (layerState.panelOwned) layerState.panel?.remove();
|
||||
else if (layerState.panel) layerState.panel.replaceChildren();
|
||||
layerState.panel = null;
|
||||
layerState.panelOwned = false;
|
||||
layerState.directionRoot?.remove();
|
||||
layerState.directionRoot = null;
|
||||
layerState.compassRing = null;
|
||||
layerState.compassLabels = [];
|
||||
layerState.compassHeading = null;
|
||||
layerState.directionMarkers = [];
|
||||
layerState.panelMarkup = '';
|
||||
layerState.subjectListener = null;
|
||||
layerState.contextListener = null;
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
cameraMotionSignature,
|
||||
syncAwarenessRenderHold,
|
||||
attachRuntimeListeners,
|
||||
detachRuntimeListeners,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
AWARENESS_MOTION_REFRESH_MS,
|
||||
AWARENESS_REFRESH_MS,
|
||||
AWARENESS_MOTION_SETTLE_MS,
|
||||
AWARENESS_REEVALUATE_DISTANCE_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
const militaryFlightsLayer = services.military;
|
||||
|
||||
/**
|
||||
* Whether focusing a Context target may take ownership of the camera.
|
||||
* Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable
|
||||
* there but must not start a competing flyTo animation.
|
||||
*
|
||||
* @param {string} layerId Context target layer id.
|
||||
* @param {HTMLElement|null} [body=document.body] Document body to inspect.
|
||||
* @returns {boolean} Whether the target may start a camera flight.
|
||||
*/
|
||||
|
||||
function contextTargetFlyToAllowed(
|
||||
layerId,
|
||||
body = globalThis.document?.body,
|
||||
) {
|
||||
const nonAircraft =
|
||||
layerId === 'ais-live-vessels' || layerId === 'military-installations';
|
||||
return !nonAircraft || !body?.classList?.contains('cockpit-mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh cadence for the Contacts direction arrows and card readouts.
|
||||
*
|
||||
* A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms
|
||||
* one. This is safe for requestRenderMode because the cadence is applied inside
|
||||
* `scene.preRender`: a faster interval can only ever consume frames the camera
|
||||
* movement is ALREADY forcing. A parked scene renders no frames at all, so no
|
||||
* extra refresh happens and no timer is introduced — the render governor and
|
||||
* the idle-leak fix both depend on parked staying quiet.
|
||||
* @param {boolean} cameraMoving Whether the camera pose changed since the last frame.
|
||||
* @returns {number} Minimum ms between refreshes.
|
||||
*/
|
||||
|
||||
function awarenessRefreshIntervalMs(cameraMoving) {
|
||||
return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, for one rendered frame, whether the view counts as MOVING and whether
|
||||
* the Contacts readout may refresh.
|
||||
*
|
||||
* Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a
|
||||
* slow drag crosses a bin only every few frames: treating a single unchanged
|
||||
* frame as "parked" made every crossing look like a motion-end and refreshed at
|
||||
* nearly display rate. Motion therefore ends only after the pose has been
|
||||
* unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD
|
||||
* floor in every state — no bin-crossing pattern, and no motion-end settle, can
|
||||
* refresh faster than AWARENESS_MOTION_REFRESH_MS.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {number} input.nowMs This frame's clock.
|
||||
* @param {number} input.lastRefreshMs When the readout last refreshed.
|
||||
* @param {number} input.lastPoseChangeMs When the camera pose last changed bins.
|
||||
* @param {boolean} input.wasMoving Whether the previous frame counted as moving.
|
||||
* @returns {{moving: boolean, refresh: boolean}}
|
||||
*/
|
||||
|
||||
function awarenessRefreshDecision({
|
||||
nowMs,
|
||||
lastRefreshMs,
|
||||
lastPoseChangeMs,
|
||||
wasMoving,
|
||||
}) {
|
||||
const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS;
|
||||
// The single frame where hysteresis expires: settle the readout at rest
|
||||
// instead of leaving it up to a parked interval stale.
|
||||
const settling = Boolean(wasMoving) && !moving;
|
||||
const sinceRefresh = nowMs - lastRefreshMs;
|
||||
const refresh =
|
||||
sinceRefresh >= awarenessRefreshIntervalMs(true) &&
|
||||
(moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false));
|
||||
return { moving, refresh };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a source-scoped clear was an eviction rather than a deselect.
|
||||
*
|
||||
* Both arrive on the same events: the owning layer calls the same teardown for
|
||||
* "the user clicked away" and "this contact aged out of the feed". Only the
|
||||
* origin separates them, and it matters — a deliberate clear takes the Contact
|
||||
* panel down, while an eviction must keep it up in its CONTACT LOST state, or
|
||||
* the panel disappears out from under its own PREVIOUS/NEXT controls.
|
||||
* @param {{reason?: string}|null} cleared Cleared source detail.
|
||||
* @returns {boolean} Whether the subject left the feed on its own.
|
||||
*/
|
||||
|
||||
function awarenessClearIsEviction(cleared) {
|
||||
return cleared?.reason === 'evicted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether proximity cohorts need an expensive rescan.
|
||||
* @param {object} input Refresh evidence.
|
||||
* @param {boolean} input.force Explicit invalidation.
|
||||
* @param {boolean} input.hasResults Whether a prior evaluation exists.
|
||||
* @param {number} input.movementM Subject displacement since evaluation.
|
||||
* @param {boolean} input.sourceRevisionChanged Whether any source state changed.
|
||||
* @returns {boolean} Whether cohorts should be evaluated again.
|
||||
*/
|
||||
|
||||
function awarenessRefreshRequired({
|
||||
force,
|
||||
hasResults,
|
||||
movementM,
|
||||
sourceRevisionChanged,
|
||||
}) {
|
||||
return Boolean(
|
||||
force ||
|
||||
!hasResults ||
|
||||
sourceRevisionChanged ||
|
||||
!Number.isFinite(movementM) ||
|
||||
movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-layer counts the Contacts panel is showing, as one flat block.
|
||||
*
|
||||
* Three honest numbers were reaching the operator at once: this cohort count
|
||||
* (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the
|
||||
* layer-wide loaded total in the coverage note. After the camera dives to a
|
||||
* tracked contact the flights layer reloads by viewport, so the loaded set can
|
||||
* hold a fraction of the cohort — 8 against the panel's 42 in the field. The
|
||||
* numbers are all correct and the disagreement still reads as chaos.
|
||||
*
|
||||
* Derived from the same snapshot the panel renders (`cohort.summary.count` via
|
||||
* `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort
|
||||
* whose feed cannot answer reports 'unknown' rather than a misleading zero.
|
||||
* @param {object|null} snapshot `getContextSnapshot()` result.
|
||||
* @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string,
|
||||
* flights: number|string, military: number|string, vessels: number|string}|null}
|
||||
* Panel-equivalent counts.
|
||||
*/
|
||||
|
||||
function contactsWindowFromSnapshot(snapshot) {
|
||||
if (!snapshot?.subject) return null;
|
||||
const countFor = (cohortId) => {
|
||||
const cohort = Array.isArray(snapshot.cohorts)
|
||||
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
||||
: null;
|
||||
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
|
||||
};
|
||||
const flights = countFor('flights');
|
||||
const military = countFor('military');
|
||||
return {
|
||||
centeredOn: snapshot.subject.label || snapshot.subject.id || null,
|
||||
radiusKm: Number.isFinite(snapshot.radiusM)
|
||||
? Math.round(snapshot.radiusM / 1000)
|
||||
: null,
|
||||
aircraft:
|
||||
Number.isFinite(flights) && Number.isFinite(military)
|
||||
? flights + military
|
||||
: 'unknown',
|
||||
flights,
|
||||
military,
|
||||
vessels: countFor('ais-live-vessels'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
|
||||
|
||||
function buildAwarenessContextSnapshot(
|
||||
results,
|
||||
navigation = {},
|
||||
{ subjectPresent = true } = {},
|
||||
) {
|
||||
if (!results) return null;
|
||||
return {
|
||||
subject: { ...results.subject },
|
||||
// Whether the subject is still reported by its source. The cockpit Contact
|
||||
// readout holds its last-known values behind a CONTACT LOST cue when this
|
||||
// is false, rather than presenting frozen geometry as a live reading.
|
||||
subjectPresent: subjectPresent !== false,
|
||||
evaluatedAt: results.evaluatedAt,
|
||||
radiusM: results.radiusM,
|
||||
cohorts: results.cohorts.map((cohort) => ({
|
||||
id: cohort.id,
|
||||
label: cohort.label,
|
||||
source: cohort.source,
|
||||
coverage: cohort.coverage || null,
|
||||
relationship: cohort.summary.relationship,
|
||||
count: cohort.summary.count,
|
||||
reason: cohort.summary.reason,
|
||||
nearest: cohort.summary.nearest.slice(),
|
||||
})),
|
||||
navigation: { ...navigation },
|
||||
};
|
||||
}
|
||||
|
||||
function navigationState() {
|
||||
return {
|
||||
canPrevious: layerState.navigationIndex > 0,
|
||||
canFocus: Boolean(layerState.subject),
|
||||
canNext: parts.navigation.canNavigateNext(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Contacts genuinely has per-frame work right now.
|
||||
*
|
||||
* The direction arrows are screen-projected against the camera basis, so they
|
||||
* must be redrawn every frame while the view MOVES. Parked, or with no live
|
||||
* cohort to point at — nothing selected, every feed empty, every feed failed —
|
||||
* there is nothing to animate and Contacts must not be the reason the whole
|
||||
* scene keeps repainting.
|
||||
* @param {object} [snapshot] Explicit state, for tests.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function awarenessNeedsContinuousRender({
|
||||
cameraMoving = layerState.cameraMoving,
|
||||
hasSubject = Boolean(layerState.subject),
|
||||
hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results),
|
||||
} = {}) {
|
||||
return Boolean(cameraMoving && hasSubject && hasLiveResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release any aircraft-owned follow camera before a non-aircraft context
|
||||
* selection takes ownership. Both layers are safe no-ops when idle and release
|
||||
* Cesium tracking in place, so the subsequent vessel/site framing starts from
|
||||
* the current view without the previous aircraft continuing to drag it.
|
||||
*/
|
||||
|
||||
function releaseAircraftTracking() {
|
||||
flightsLayer.stopTracking?.();
|
||||
militaryFlightsLayer.stopTracking?.();
|
||||
}
|
||||
return {
|
||||
contextTargetFlyToAllowed,
|
||||
awarenessRefreshIntervalMs,
|
||||
awarenessRefreshDecision,
|
||||
awarenessClearIsEviction,
|
||||
awarenessRefreshRequired,
|
||||
contactsWindowFromSnapshot,
|
||||
buildAwarenessContextSnapshot,
|
||||
navigationState,
|
||||
awarenessNeedsContinuousRender,
|
||||
releaseAircraftTracking,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
summarizeAwarenessCohort,
|
||||
getAwarenessNavigationTargets,
|
||||
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
findByDoublingRadius,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import {
|
||||
AWARENESS_MAX_EXAMPLES,
|
||||
AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||
} from './policy.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const flightsLayer = services.flights;
|
||||
const militaryFlightsLayer = services.military;
|
||||
|
||||
function summarizeAwarenessCohortForNavigation(
|
||||
items,
|
||||
source,
|
||||
{
|
||||
displayLimit = AWARENESS_MAX_EXAMPLES,
|
||||
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||
} = {},
|
||||
) {
|
||||
const summary = summarizeAwarenessCohort(items, {
|
||||
...source,
|
||||
limit: navigationLimit,
|
||||
});
|
||||
if (summary.count === null) return summary;
|
||||
return {
|
||||
...summary,
|
||||
nearest: summary.nearest.slice(0, displayLimit),
|
||||
navigationNearest: summary.nearest,
|
||||
};
|
||||
}
|
||||
|
||||
function resetNavigationVisitedCycle() {
|
||||
layerState.navigationVisited.clear();
|
||||
const currentKey = parts.subject.subjectKey(layerState.subject);
|
||||
if (currentKey) layerState.navigationVisited.add(currentKey);
|
||||
}
|
||||
|
||||
function isFlightLayer(layerId) {
|
||||
return layerId === 'flights' || layerId === 'military';
|
||||
}
|
||||
|
||||
function normalizeAircraftClass(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function aircraftClassMatchesFilter(item, aircraftClass) {
|
||||
const requested = normalizeAircraftClass(aircraftClass);
|
||||
if (!requested) return true;
|
||||
const candidates = [
|
||||
item?.aircraftClass,
|
||||
item?.klass,
|
||||
item?.type,
|
||||
item?.typeCode,
|
||||
item?.typeName,
|
||||
]
|
||||
.map((value) => normalizeAircraftClass(value))
|
||||
.filter(Boolean);
|
||||
if (!candidates.length) return false;
|
||||
return candidates.some(
|
||||
(candidate) => candidate === requested || candidate.includes(requested),
|
||||
);
|
||||
}
|
||||
|
||||
function selectNavigationTargets(
|
||||
sourceCohorts,
|
||||
subject,
|
||||
visitedKeys,
|
||||
{ targetLayer = null, aircraftClass = null, aircraftOnly = false } = {},
|
||||
) {
|
||||
let targets = getAwarenessNavigationTargets(
|
||||
sourceCohorts,
|
||||
subject,
|
||||
visitedKeys,
|
||||
);
|
||||
if (aircraftOnly) {
|
||||
targets = targets.filter((target) => isFlightLayer(target.layerId));
|
||||
}
|
||||
if (targetLayer) {
|
||||
targets = targets.filter((target) => target.layerId === targetLayer);
|
||||
}
|
||||
if (aircraftClass) {
|
||||
targets = targets.filter((target) =>
|
||||
aircraftClassMatchesFilter(target.item, aircraftClass),
|
||||
);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function alternativeFlightAvailable({
|
||||
targetLayer = null,
|
||||
aircraftClass = null,
|
||||
} = {}) {
|
||||
if (
|
||||
!isFlightLayer(layerState.subject?.layerId) ||
|
||||
!layerState.subject?.position
|
||||
)
|
||||
return false;
|
||||
if (targetLayer && !isFlightLayer(targetLayer)) return false;
|
||||
|
||||
const flights =
|
||||
targetLayer === 'military'
|
||||
? []
|
||||
: flightsLayer
|
||||
.getNearby(
|
||||
layerState.subject.position,
|
||||
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
2,
|
||||
{ includeHidden: true },
|
||||
)
|
||||
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
|
||||
|
||||
const military =
|
||||
targetLayer === 'flights'
|
||||
? []
|
||||
: militaryFlightsLayer
|
||||
.getNearby(
|
||||
layerState.subject.position,
|
||||
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
2,
|
||||
{ includeHidden: true },
|
||||
)
|
||||
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
|
||||
|
||||
return [
|
||||
['flights', flights],
|
||||
['military', military],
|
||||
].some(([layerId, items]) =>
|
||||
items.some(
|
||||
(item) =>
|
||||
`${layerId}:${item.icao24 || item.id}` !==
|
||||
parts.subject.subjectKey(layerState.subject),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function closestFlightWithinRadius(
|
||||
radiusM,
|
||||
visitedKeys,
|
||||
excludeVisited,
|
||||
{ targetLayer = null, aircraftClass = null } = {},
|
||||
) {
|
||||
if (!layerState.subject?.position) return null;
|
||||
const visited = new Set(visitedKeys);
|
||||
if (targetLayer && !isFlightLayer(targetLayer)) return null;
|
||||
const candidates = [
|
||||
...(targetLayer === 'military'
|
||||
? []
|
||||
: flightsLayer
|
||||
.getNearby(layerState.subject.position, radiusM, 25000, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
|
||||
.map((item) => ({
|
||||
layerId: 'flights',
|
||||
id: String(item.icao24),
|
||||
item,
|
||||
}))),
|
||||
...(targetLayer === 'flights'
|
||||
? []
|
||||
: militaryFlightsLayer
|
||||
.getNearby(layerState.subject.position, radiusM, 5000, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
|
||||
.map((item) => ({
|
||||
layerId: 'military',
|
||||
id: String(item.icao24),
|
||||
item,
|
||||
}))),
|
||||
].filter((target) => {
|
||||
const key = `${target.layerId}:${target.id}`;
|
||||
if (key === parts.subject.subjectKey(layerState.subject)) return false;
|
||||
return !excludeVisited || !visited.has(key);
|
||||
});
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
(a.item.distanceM ?? a.item.distance ?? Infinity) -
|
||||
(b.item.distanceM ?? b.item.distance ?? Infinity),
|
||||
);
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
function findExpandedFlightTarget(options = {}) {
|
||||
if (!isFlightLayer(layerState.subject?.layerId)) return null;
|
||||
let visitedKeys = [...layerState.navigationVisited];
|
||||
const search = () =>
|
||||
findByDoublingRadius(
|
||||
(radiusM) =>
|
||||
closestFlightWithinRadius(radiusM, visitedKeys, true, options),
|
||||
{
|
||||
initialRadiusM: AWARENESS_RADIUS_M,
|
||||
maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||
},
|
||||
);
|
||||
const target = search();
|
||||
if (target) return target;
|
||||
resetNavigationVisitedCycle();
|
||||
visitedKeys = [...layerState.navigationVisited];
|
||||
return search();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves NEXT availability from the same three branches used by navigation.
|
||||
* Forward history remains usable while a feed is unavailable; discovering a
|
||||
* new nearby or expanded target requires a known subject cohort.
|
||||
* @param {Object} availability Navigation branch availability.
|
||||
* @returns {boolean} Whether NEXT has a path that can run.
|
||||
*/
|
||||
|
||||
function canNavigateAwarenessNext({
|
||||
hasForwardHistory = false,
|
||||
hasExpandedFlightTarget = false,
|
||||
hasNearbyTarget = false,
|
||||
subjectCohortUnknown = false,
|
||||
} = {}) {
|
||||
return Boolean(
|
||||
hasForwardHistory ||
|
||||
(!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)),
|
||||
);
|
||||
}
|
||||
|
||||
function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) {
|
||||
return canNavigateAwarenessNext({
|
||||
hasForwardHistory:
|
||||
parts.history.findCompatibleHistoryIndex(
|
||||
layerState.navigationHistory,
|
||||
layerState.navigationIndex,
|
||||
1,
|
||||
{
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
resolveItem: parts.history.historySubjectItem,
|
||||
},
|
||||
) !== -1,
|
||||
hasNearbyTarget:
|
||||
selectNavigationTargets(
|
||||
layerState.results?.cohorts,
|
||||
layerState.subject,
|
||||
[...layerState.navigationVisited],
|
||||
{ targetLayer, aircraftClass },
|
||||
).length > 0,
|
||||
hasExpandedFlightTarget: alternativeFlightAvailable({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
}),
|
||||
subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(),
|
||||
});
|
||||
}
|
||||
return {
|
||||
summarizeAwarenessCohortForNavigation,
|
||||
resetNavigationVisitedCycle,
|
||||
isFlightLayer,
|
||||
normalizeAircraftClass,
|
||||
aircraftClassMatchesFilter,
|
||||
selectNavigationTargets,
|
||||
alternativeFlightAvailable,
|
||||
closestFlightWithinRadius,
|
||||
findExpandedFlightTarget,
|
||||
canNavigateAwarenessNext,
|
||||
canNavigateNext,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
const existing = document.getElementById('military-awareness-panel');
|
||||
const panel = existing || document.createElement('aside');
|
||||
layerState.panelOwned = !existing;
|
||||
if (!existing) {
|
||||
panel.id = 'military-awareness-panel';
|
||||
}
|
||||
layerState.panelClickListener = (event) => {
|
||||
const action = event.target.closest('button[data-awareness-action]');
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
if (action.dataset.awarenessAction === 'previous')
|
||||
parts.history.navigateHistory(-1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'next')
|
||||
parts.history.navigateHistory(1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'focus')
|
||||
parts.focus.focusCurrentSubject({ origin: 'user' });
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest(
|
||||
'button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
parts.focus.requestFocus(
|
||||
target.dataset.awarenessLayer,
|
||||
target.dataset.awarenessId,
|
||||
false,
|
||||
{ origin: 'user' },
|
||||
);
|
||||
};
|
||||
panel.addEventListener('click', layerState.panelClickListener);
|
||||
if (!existing) document.body.appendChild(panel);
|
||||
layerState.panel = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
if (layerState.panel) {
|
||||
const markup = `<div class="military-awareness-standby">
|
||||
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
|
||||
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
|
||||
</div>`;
|
||||
layerState.panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
layerState.panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
}
|
||||
}
|
||||
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
|
||||
}
|
||||
|
||||
function rowHtml(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = summary.count === null ? '?' : String(summary.count);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel = label === '—' ? 'Unavailable' : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||
);
|
||||
const pageLabel =
|
||||
pageCount > 1
|
||||
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
||||
: '';
|
||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
|
||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function navigationControlsHtml() {
|
||||
const canPrevious = layerState.navigationIndex > 0;
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
|
||||
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
|
||||
<button type="button" data-awareness-action="focus">FOCUS</button>
|
||||
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||
|
||||
function awarenessPanelControlKey(element) {
|
||||
const control = element?.closest?.(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!control) return null;
|
||||
if (control.dataset.awarenessAction)
|
||||
return `action:${control.dataset.awarenessAction}`;
|
||||
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||
}
|
||||
|
||||
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||
|
||||
function captureAwarenessPanelFocus(
|
||||
panel,
|
||||
activeElement = document.activeElement,
|
||||
) {
|
||||
if (!panel?.contains?.(activeElement)) return null;
|
||||
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||
return { key: 'continuation' };
|
||||
}
|
||||
const key = awarenessPanelControlKey(activeElement);
|
||||
if (!key) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||
|
||||
function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||
if (!panel || !snapshot) return null;
|
||||
const continuation = panel.querySelector(
|
||||
'[data-awareness-focus-continuation]',
|
||||
);
|
||||
const controls = [
|
||||
...panel.querySelectorAll(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
),
|
||||
].filter((control) => !control.disabled);
|
||||
const retained =
|
||||
snapshot.key === 'continuation'
|
||||
? continuation
|
||||
: controls.find(
|
||||
(control) => awarenessPanelControlKey(control) === snapshot.key,
|
||||
);
|
||||
const target = retained || continuation;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
return target || null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>'"]/g,
|
||||
(char) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[char],
|
||||
);
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||
panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function rotateAwarenessPages() {
|
||||
if (!layerState.enabled || !layerState.results) return;
|
||||
let changed = false;
|
||||
for (const cohort of layerState.results.cohorts) {
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||
);
|
||||
if (pageCount <= 1) continue;
|
||||
const current = layerState.cohortPages.get(cohort.id) || 0;
|
||||
layerState.cohortPages.set(
|
||||
cohort.id,
|
||||
((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) *
|
||||
AWARENESS_PAGE_SIZE,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
renderResults();
|
||||
parts.rendering.scheduleDirectionOverlayUpdate(true);
|
||||
}
|
||||
}
|
||||
|
||||
function startAwarenessPageRotation() {
|
||||
if (!layerState.pageTimer)
|
||||
layerState.pageTimer = window.setInterval(
|
||||
rotateAwarenessPages,
|
||||
AWARENESS_PAGE_ROTATE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function stopAwarenessPageRotation() {
|
||||
if (layerState.pageTimer) window.clearInterval(layerState.pageTimer);
|
||||
layerState.pageTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an evaluation actually has a contact to point an arrow at.
|
||||
*
|
||||
* A cohort reports `count: null` when its feed is unavailable or stale, and `0`
|
||||
* when the feed is healthy but empty. Only a positive count puts a marker on
|
||||
* the compass rim, so anything else means there is nothing to animate.
|
||||
* @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function awarenessResultsAreLive(results) {
|
||||
const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : [];
|
||||
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
||||
}
|
||||
return {
|
||||
ensurePanel,
|
||||
hidePanel,
|
||||
rowHtml,
|
||||
navigationControlsHtml,
|
||||
awarenessPanelControlKey,
|
||||
captureAwarenessPanelFocus,
|
||||
restoreAwarenessPanelFocus,
|
||||
escapeHtml,
|
||||
renderResults,
|
||||
rotateAwarenessPages,
|
||||
startAwarenessPageRotation,
|
||||
stopAwarenessPageRotation,
|
||||
awarenessResultsAreLive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
|
||||
|
||||
export const DEFERRED_DEPENDENCIES = [
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
];
|
||||
|
||||
export const DEPENDENCIES = [
|
||||
...AIRCRAFT_DEPENDENCIES,
|
||||
...DEFERRED_DEPENDENCIES,
|
||||
];
|
||||
|
||||
export const AWARENESS_REFRESH_MS = 750;
|
||||
|
||||
/** @constant {number} Refresh cadence while the camera pose is CHANGING.
|
||||
* Owner playtest 2026-08-18: the Contacts direction arrows and card readouts
|
||||
* "feel sluggish when you look around" — at the parked 750 ms cadence the
|
||||
* arrows lag the view by up to three quarters of a second. */
|
||||
|
||||
export const AWARENESS_MOTION_REFRESH_MS = 175;
|
||||
|
||||
/** @constant {number} How long the pose signature must stay UNCHANGED before
|
||||
* the view counts as parked again. The signature is quantized, so a slow drag
|
||||
* crosses a bin only every few frames; without this hysteresis every crossing
|
||||
* read as a motion-end and refreshed at nearly display rate. */
|
||||
|
||||
export const AWARENESS_MOTION_SETTLE_MS = 250;
|
||||
|
||||
export const AWARENESS_REEVALUATE_DISTANCE_M = 250;
|
||||
|
||||
export const AWARENESS_PAGE_ROTATE_MS = 10000;
|
||||
|
||||
export const AWARENESS_PAGE_SIZE = 3;
|
||||
|
||||
export const AWARENESS_MAX_EXAMPLES = 10;
|
||||
|
||||
// Navigation walks the whole 250 km cohort, not the ten rows the panel shows,
|
||||
// so these caps sit far above any realistic in-range population rather than at
|
||||
// the display limit. They are still FINITE: an unbounded materialization would
|
||||
// let a pathological feed sort an eleven-thousand-contact array on every
|
||||
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
|
||||
// derives `count` from the full in-range set before any slice.
|
||||
|
||||
export const AWARENESS_QUERY_LIMIT = 20000;
|
||||
|
||||
export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
|
||||
|
||||
export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||
|
||||
export const VESSEL_FOCUS_RADIUS_M = 3000;
|
||||
|
||||
export const SOURCE_LABEL = {
|
||||
flights: 'OpenSky',
|
||||
military: 'adsb.lol',
|
||||
'ais-live-vessels': 'AISStream',
|
||||
'military-installations': 'OpenStreetMap',
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a refresh tick could confirm the subject in its source collection.
|
||||
* UNCHECKED means the tick did not look — it must never change the verdict.
|
||||
*/
|
||||
|
||||
export const SUBJECT_PRESENCE = Object.freeze({
|
||||
LIVE: 'live',
|
||||
MISSING: 'missing',
|
||||
UNCHECKED: 'unchecked',
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||
|
||||
export function createQueries({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
const militaryFlightsLayer = services.military;
|
||||
|
||||
function sourceState(layerId) {
|
||||
const lifecycle =
|
||||
layerState.dataManager?.getLayerLifecycleState?.(layerId) || null;
|
||||
const enabled =
|
||||
lifecycle?.enabled === true ||
|
||||
layerState.dataManager?.isEnabled(layerId) === true;
|
||||
const enabling = lifecycle?.lifecycleState === 'enabling';
|
||||
const moduleStats =
|
||||
layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {};
|
||||
const stats = enabling
|
||||
? { ...moduleStats, loading: true, status: 'loading' }
|
||||
: moduleStats;
|
||||
// A deferred source that has started but not settled is not evidence of an
|
||||
// empty cohort. Keep it explicitly unavailable so the panel cannot flash a
|
||||
// false all-clear, even if a lifecycle adapter briefly reports the requested
|
||||
// visibility intent as enabled.
|
||||
//
|
||||
// `enabling` and the never-answered predicate cover two DIFFERENT windows, and
|
||||
// both are needed because these dependencies do not settle the same way.
|
||||
//
|
||||
// - military-installations is covered by `enabling` alone. Its enable() is
|
||||
// SYNCHRONOUS and the manager awaits update(), which owns the first
|
||||
// Overpass fetch, so the lifecycle stays `enabling` for that whole window
|
||||
// however long the fetch takes. Confirmed live: a held 17 s first fetch
|
||||
// read `enabling` across 34 samples with the panel non-numeric throughout,
|
||||
// and a failing one settled to `enabled` with status 'unavailable'. Its
|
||||
// getStats() also reports loading while a request is in progress —
|
||||
// setInstallationStatus is only ever called with
|
||||
// loading/zoom-in/ready/stale/empty/unavailable.
|
||||
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
|
||||
// update() both resolve as soon as the first /api/ais-live poll answers,
|
||||
// so the lifecycle settles to `enabled` — but until the server-side socket
|
||||
// delivers a position, firstConnectPhase is 'loading' and getStats()
|
||||
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
|
||||
// status. That zero is an absence of evidence, not evidence of absence,
|
||||
// and neither the lifecycle nor the status list can tell.
|
||||
//
|
||||
// Deliberately `loading === true` and not truthiness: layers that never report
|
||||
// a busy flag (flights, military) must keep answering for themselves, and a
|
||||
// source that HAS answered once (lastUpdate set) keeps its last real count
|
||||
// through every later poll rather than blanking to `?` on each refresh.
|
||||
const neverAnswered = stats.loading === true && !stats.lastUpdate;
|
||||
const unavailable =
|
||||
enabling ||
|
||||
!enabled ||
|
||||
neverAnswered ||
|
||||
['unavailable', 'zoom-in'].includes(stats.status) ||
|
||||
Boolean(stats.error && stats.count === 0);
|
||||
return { available: !unavailable, stale: Boolean(stats.stale), stats };
|
||||
}
|
||||
|
||||
function collectSourceStates() {
|
||||
return Object.fromEntries(
|
||||
DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRevision(sourceStates) {
|
||||
return DEPENDENCIES.map((layerId) => {
|
||||
const source = sourceStates[layerId];
|
||||
const stats = source?.stats || {};
|
||||
return [
|
||||
layerId,
|
||||
source?.available,
|
||||
source?.stale,
|
||||
stats.lastUpdate || null,
|
||||
stats.count ?? null,
|
||||
stats.status || null,
|
||||
stats.error || null,
|
||||
];
|
||||
})
|
||||
.map((parts) => parts.join(':'))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function isSame(subject, item, prefix, key) {
|
||||
return (
|
||||
subject?.layerId === prefix && String(subject.id) === String(item?.[key])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize viewport-backed installations without implying radius-complete coverage.
|
||||
* @param {Array<object>} items Loaded installation matches.
|
||||
* @param {object} source Current source availability and staleness.
|
||||
* @returns {object} Awareness summary with viewport-honest reasoning.
|
||||
*/
|
||||
|
||||
function summarizeInstallationViewport(items, source) {
|
||||
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||
items,
|
||||
source,
|
||||
);
|
||||
if (summary.count === null)
|
||||
return source.stats?.statusMessage
|
||||
? { ...summary, reason: source.stats.statusMessage }
|
||||
: summary;
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? 'mapped matches from the loaded viewport'
|
||||
: 'viewport feed is not a complete 250 km survey',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE aircraft-proximity engine. One computation, two consumers: the Contacts
|
||||
* panel window and the voice analyst's entity-centred "how many nearby".
|
||||
*
|
||||
* They used to be separate. The panel read live billboard positions through
|
||||
* `getNearby` with a 20 000 cap; the analyst re-derived its own answer from
|
||||
* last-fix coordinates over a 2 000-record slice. Same question, same centre,
|
||||
* two numbers — and in the owner's trial the spoken answer (15) and the panel
|
||||
* (111) disagreed badly enough that the model narrated the difference away.
|
||||
* Routing both through here makes them the same number BY CONSTRUCTION, so
|
||||
* they cannot drift again.
|
||||
*
|
||||
* The subject is excluded from its own window, which is why the panel reads
|
||||
* "contacts around X" rather than "including X".
|
||||
* @param {Cesium.Cartesian3} position Window centre.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
|
||||
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
|
||||
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
|
||||
* plus the combined aircraft count, or null without a position.
|
||||
*/
|
||||
|
||||
function collectAircraftProximityWindow(
|
||||
position,
|
||||
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
|
||||
) {
|
||||
if (!position) return null;
|
||||
const flights = flightsLayer
|
||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter(
|
||||
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
|
||||
);
|
||||
const military = militaryFlightsLayer
|
||||
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||
includeHidden: true,
|
||||
})
|
||||
.filter(
|
||||
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
|
||||
);
|
||||
return { flights, military, aircraft: flights.length + military.length };
|
||||
}
|
||||
return {
|
||||
sourceState,
|
||||
collectSourceStates,
|
||||
sourceRevision,
|
||||
isSame,
|
||||
summarizeInstallationViewport,
|
||||
collectAircraftProximityWindow,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user