diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f654587..dc297d5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TESTING.md b/TESTING.md index fb9ca960a..ed070a03d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 607315d4b..abb07a114 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61d996883..6b60bde92 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/package.json b/package.json index aae850dc0..aacbf0140 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f8717b16..b96788889 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 91296df96..4218a8a10 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] } } diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs new file mode 100644 index 000000000..7eb2aeca7 --- /dev/null +++ b/scripts/qa-camera-controls.mjs @@ -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: `QA CAMERA ${cameraA ? 'A' : 'B'}`, + }) + .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; diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 0b636f21b..bfb15f8ca 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -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'); diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 1f67ea7db..5c3829462 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -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, diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index dd93013e6..d32ede533 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -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') }); } } diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 615398699..0e2ac9d62 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -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(); diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs new file mode 100644 index 000000000..453009929 --- /dev/null +++ b/scripts/qa-layer-panel.mjs @@ -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: 'Literal layer', + 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 === + 'Literal layer' && !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; diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs new file mode 100644 index 000000000..606d67b83 --- /dev/null +++ b/scripts/qa-location-controls.mjs @@ -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; diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs new file mode 100644 index 000000000..c03e96a79 --- /dev/null +++ b/scripts/qa-map-source-controls.mjs @@ -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; diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 5d5854bc6..f6c0b0f54 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -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'), diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 50d5b50c6..c5629d2f4 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -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()); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs new file mode 100644 index 000000000..f5c18dfed --- /dev/null +++ b/scripts/qa-scene-controls.mjs @@ -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 = 'QA shot'; + await page.click('.scene-shot-label', { count: 2 }); + await page.waitForFunction( + () => + document.querySelector('.scene-shot-label')?.textContent === + 'QA shot', + ); + 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 === 'QA shot'), + ), + ), + ); + 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('QA shot'), + ), + ); + 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; diff --git a/scripts/qa-traffic-navigation.mjs b/scripts/qa-traffic-navigation.mjs new file mode 100644 index 000000000..b45daeb2c --- /dev/null +++ b/scripts/qa-traffic-navigation.mjs @@ -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(); +} diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 04a4e8927..38382f8c4 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -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. diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs new file mode 100644 index 000000000..d393648df --- /dev/null +++ b/scripts/qa-ui-disposal.mjs @@ -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; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index 51978292e..b5ad8ac87 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -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 :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---.*. * 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'); diff --git a/scripts/qa-visual-effects.mjs b/scripts/qa-visual-effects.mjs new file mode 100644 index 000000000..db0696e08 --- /dev/null +++ b/scripts/qa-visual-effects.mjs @@ -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; diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 4b1fd7df8..c5832ecd4 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -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, diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs new file mode 100644 index 000000000..54ad48358 --- /dev/null +++ b/scripts/traffic-fixtures.mjs @@ -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 }); +} diff --git a/src/app/stateChannel.js b/src/app/stateChannel.js new file mode 100644 index 000000000..93403b044 --- /dev/null +++ b/src/app/stateChannel.js @@ -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; + }, + }); +} diff --git a/src/app/stateChannel.test.mjs b/src/app/stateChannel.test.mjs new file mode 100644 index 000000000..1cb34339c --- /dev/null +++ b/src/app/stateChannel.test.mjs @@ -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); +}); diff --git a/src/bloom.js b/src/bloom.js index 297d93ee6..fba0472d2 100644 --- a/src/bloom.js +++ b/src/bloom.js @@ -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); } diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index e7d5b0268..2b8d3367f 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -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\(\{/); diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 404831aa0..faf70f9f6 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -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'\)/); +}); diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index fee23eb5e..64b4c20de 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -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/, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 2090667c8..24884b5ad 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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 { @@ -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'); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index fc3e2135f..a0bd83676 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -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 diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index 56d5c9d33..57e9ecd6f 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -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'); diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js index dea0a77a3..0d6c09327 100644 --- a/src/cockpitUtilityLayout.js +++ b/src/cockpitUtilityLayout.js @@ -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({ ), }; } - diff --git a/src/cockpitVisionPolicy.js b/src/cockpitVisionPolicy.js index 40602df2e..c49aa35da 100644 --- a/src/cockpitVisionPolicy.js +++ b/src/cockpitVisionPolicy.js @@ -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', diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 0dcb5a6f0..331151f8e 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -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( diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 8aff666fd..af836c793 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -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\]/, diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 7547e0f52..49a640d97 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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*=/); }); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index dc1c17136..593fb5395 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -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', () => { diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index d55678ca8..9a63a36cd 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -1,2109 +1,70 @@ -import * as Cesium from 'cesium'; -import { - registerEntityContext, - selectEntityContext, - clearSelectedEntityContextForLayer, -} from './contextStore.js'; -import { createTrail } from './trailRenderer.js'; -import { screenProjectedRotation, cameraPoseSignature } from './iconOrientation.js'; -import { formatKnots } from './detectionDraw.js'; -import { - isOwnedByOtherLayer, - registerPickOwner, - unregisterPickOwner, - resolvePickId, -} from './pickRegistry.js'; -import { - applyVesselOverlayPolicy, - accentForVesselType, - VESSEL_CARD_FADE_DISTANCE_M, - VESSEL_LABEL_GRID_PX, - VESSEL_OVERLAY_SOURCE_ID, - vesselTypeCss, - vesselOverlayCohortLimit, - normalizeVesselType, -} from './vesselLabels.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { ensureGeoidReady, geoidHeight } from './geoid.js'; -import { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, -} from './spriteOrder.js'; -import { - advanceSpriteFocus, - focusNowMs, - focusAlphaNeedsWrite, - focusPassIsNeeded, - forgetSpriteFocus, - getFocusTarget, -} from './focusDeemphasis.js'; -import { requestWorldFocus } from '../worldFocus.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +import { createVesselLayer } from '../layers/vessels/index.js'; +import { createAisStreamSource } from '../sources/live/standalone.js'; +import * as context from './contextStore.js'; +import * as trails from './trailRenderer.js'; +import * as labels from './detectionDraw.js'; +import * as picking from './pickRegistry.js'; +import * as overlay from '../overlays/worldOverlay.js'; +import * as geoid from './geoid.js'; +import * as sprites from './spriteOrder.js'; +import * as focus from './focusDeemphasis.js'; +import * as worldFocus from '../worldFocus.js'; +import * as render from '../renderGovernor.js'; -const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; - -/** Camera pose signature at the last vessel rotation pass. */ -let _lastCamPoseSig = ''; -const _scratchFocusScreen = new Cesium.Cartesian2(); - -const DEFAULT_API_URL = '/api/ais-live'; -const DEFAULT_RENDER_ROWS = 12000; -const DEFAULT_ACTIVE_LABELS = 900; -const REFRESH_MS = 60000; -/** Bounded wait for the first accepted vessel position in one enabled session. */ -export const AIS_FIRST_CONNECT_GRACE_MS = 30000; -const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…'; -const VISIBILITY_UPDATE_MS = 800; -/** Focus alpha alone samples faster inside the existing preRender pass. */ -const FOCUS_UPDATE_MS = 80; -const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX; -/** - * Minimum screen-space separation between accepted vessel cards (matches the - * FIRMS card declutter). The 118px grid alone under-spaces the wider canvas - * cards; the greedy pass below enforces true card-scale spacing. - */ -const CARD_MIN_SEP_PX = 150; -/** Number of consecutive refreshes a selected-but-vanished vessel is retained. */ -const SELECTED_PIN_REFRESHES = 3; -/** Trail hue for the selected vessel (PRD F4, pinned to the AIS teal-green family). */ -const TRAIL_COLOR = '#39ffd5'; -/** Slight lift (m) for trail vertices to avoid sea-surface z-fighting. */ -const TRAIL_HEIGHT_M = 3; -/** - * Lift (m) above the local sea surface (geoid) for vessel anchors — locked - * height-datum principle #1: never below the visible surface, slightly above - * is always fine (clears tide/mesh noise in the photoreal sea mesh). - */ -const VESSEL_LIFT_M = 3; -/** Combined cap on trail vertices (server backfill + live accumulation). */ -const TRAIL_MAX_POINTS = 400; -/** Minimum movement (m) before a reconcile refresh appends a new trail point. */ -const TRAIL_MIN_MOVE_M = 25; - -const DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, +const aisLiveVesselsLayer = createVesselLayer({ + source: createAisStreamSource({ + apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + }), + options: { + maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, + maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS, + }, + services: { + context, + trails, + labels, + picking, + overlay, + geoid, + sprites, + focus, + worldFocus, + render, + }, }); -let _vesselOverlayHost = DEFAULT_VESSEL_OVERLAY_HOST; - -const DEFAULT_AIS_RUNTIME = Object.freeze({ - now: () => Date.now(), - setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimeout: (timer) => clearTimeout(timer), -}); -let _aisRuntime = DEFAULT_AIS_RUNTIME; -let _aisSessionSequence = 0; - -/** - * Human-readable reasons for a non-'open' AISStream feed status, keyed to the - * server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in - * the layer chip so a dead feed reads "feed down" instead of a healthy-looking - * "just now · 0 vessels". - */ -const AIS_STATUS_REASON = { - 'missing-key': 'AISSTREAM_API_KEY not set', - unsupported: 'live feed unsupported', - connecting: 'connecting to feed…', - closed: 'feed disconnected', - error: 'feed down', - idle: 'feed idle', -}; - -/** - * Statuses in which fresh data is flowing. 'open' is the pre-watchdog spelling - * and is still accepted so a cached bundle and a restarted server never - * disagree about health. - */ -const AIS_HEALTHY_STATUSES = new Set(['live', 'open']); - -/** - * Server statuses meaning "the feed is not delivering right now". These are - * surfaced even while cached vessels are still drawn: rows retained from - * before the outage must never make a dead feed read as a healthy one. - */ -const AIS_DEGRADED_STATUSES = new Set(['stale', 'reconnecting', 'down', 'auth-failed']); - -/** - * Seconds until the server's next reconnect attempt, or 0 when none is - * scheduled. Mirrors the flights layer's `retryInSec` chip affordance. - * @returns {number} - */ -function aisRetryInSec() { - // A rejected key is terminal until someone changes it; an hour-long - // countdown would imply waiting is the fix. - if (state.transportStatus === 'auth-failed') return 0; - const at = Number(state.nextAttemptAt); - if (!Number.isFinite(at) || at <= 0) return 0; - return Math.max(0, Math.ceil((at - _aisRuntime.now()) / 1000)); -} - -/** - * Chip text for a feed the server has reported as not delivering. - * @param {string} status - 'stale' | 'reconnecting' | 'down' - * @param {Object} payload - Parsed /api/ais-live JSON. - * @returns {string} - */ -function describeDegradedAisFeed(status, payload) { - if (status === 'auth-failed') { - // Actionable, not a countdown: retrying cannot fix a rejected credential, - // so the chip asks the operator to do the one thing that can. - return 'API key rejected — check AISSTREAM_API_KEY'; - } - if (status === 'stale') { - const silentSec = Math.round(Number(payload?.silentForMs) / 1000); - return Number.isFinite(silentSec) && silentSec > 0 - ? `feed silent ${silentSec}s — no AIS data` - : 'feed silent — no AIS data'; - } - const attempt = Number(payload?.reconnectAttempt); - const suffix = Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : ''; - return status === 'down' - ? `feed down — retrying slowly${suffix}` - : `reconnecting to feed…${suffix}`; -} - -/** - * Derive a surfaced error string from an /api/ais-live payload, or null when the - * feed has accepted product data. Socket transport, message receipt, and usable - * vessel positions are separate health stages: an open socket with no message - * or no accepted positions must not read as a fresh successful update. - * - * @param {Object|null|undefined} payload - Parsed /api/ais-live JSON. - * @param {number} acceptedRowCount - Number of rows accepted by vessel normalization. - * @returns {string|null} A short reason for the chip, or null if healthy. - */ -export function deriveAisFeedError(payload, acceptedRowCount) { - const status = payload && typeof payload.status === 'string' ? payload.status : null; - // A feed the server reports as not delivering outranks the row count: the - // cached vessels on screen are exactly what makes an outage invisible. - if (status && AIS_DEGRADED_STATUSES.has(status)) { - return describeDegradedAisFeed(status, payload); - } - if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable - if (AIS_HEALTHY_STATUSES.has(status)) { - return payload?.lastMessageAt - ? 'awaiting usable AIS positions…' - : 'awaiting first AIS message…'; - } - if (!status) return null; - const detail = typeof payload.error === 'string' && payload.error.trim() ? payload.error.trim() : ''; - const reason = AIS_STATUS_REASON[status] || 'feed unavailable'; - return detail && !AIS_STATUS_REASON[status] ? `${reason} (${detail})` : reason; -} - -/** True when a raw AIS row can enter the production vessel normalizer. */ -function hasUsableVesselCoordinates(row) { - return Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon)); -} - -/** - * Classify one server snapshot before any destructive reconciliation. - * @param {Object|null|undefined} payload - Parsed /api/ais-live payload. - * @returns {{transportStatus: string|null, lastMessageAt: number|string|null, - * rawRows: Array, acceptedRows: Array, rawRowCount: number, - * acceptedRowCount: number, error: string|null}} - */ -export function classifyAisFeedSnapshot(payload) { - const rawRows = Array.isArray(payload?.rows) ? payload.rows : []; - const acceptedRows = rawRows.filter(hasUsableVesselCoordinates); - const transportStatus = typeof payload?.status === 'string' ? payload.status : null; - const lastMessageAt = payload?.lastMessageAt ?? null; - const acceptedRowCount = acceptedRows.length; - return { - transportStatus, - lastMessageAt, - rawRows, - acceptedRows, - rawRowCount: rawRows.length, - acceptedRowCount, - error: deriveAisFeedError(payload, acceptedRowCount) - || (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null), - }; -} - -/** - * Map one internal vessel record to a plain JSON-safe analyst record - * (analyst query engine seam). Pure — no Cesium types. Missing/unknown - * fields are null, never NaN/undefined. navStatus is always null: the - * /api/ais-live proxy does not surface AIS NavigationalStatus, so it - * cannot be derived client-side. - * @param {Object|null|undefined} record - `state.vesselMap`/`state.vesselRecords` entry. - * @returns {{id: string|null, mmsi: string|null, name: string|null, - * lat: number|null, lon: number|null, speedKts: number|null, - * courseDeg: number|null, shipType: string|null, destination: string|null, - * navStatus: null}} - */ -export function mapAnalystRecord(record) { - const num = (v) => (Number.isFinite(v) ? v : null); - const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; - const mmsi = text(record?.mmsi); - const name = text(record?.name); - return { - id: name || mmsi, - mmsi, - name, - lat: num(record?.lat), - lon: num(record?.lon), - speedKts: num(record?.speed), - courseDeg: num(record?.course), - shipType: text(record?.type), - destination: text(record?.destination), - navStatus: null, - }; -} - -/** - * True once the EGM96 geoid grid has loaded (fire-and-forget warm at - * enable(), aircraft idiom — see militaryFlights.js). Gates all synchronous - * geoidHeight() reads so a poll can never throw pre-load. - * @type {boolean} - */ -let _geoidReady = false; - -/** - * Ellipsoidal render height (m) for a sea-surface object: the local geoid - * undulation N plus a small lift. The sea surface ≈ the geoid, which sits - * −106…+85 m off the WGS84 ellipsoid worldwide (Rotterdam ≈ +45 m — at - * height 0 the tile sea mesh occludes every chevron; Houston ≈ −27 m). - * Pure seam, exported for unit tests. - * @param {number|null|undefined} geoidN - Undulation N (m), or null/undefined while the grid is cold. - * @param {number} liftM - Lift above the sea surface (m). - * @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent). - */ -export function vesselDatumHeightM(geoidN, liftM) { - return (Number.isFinite(geoidN) ? geoidN : 0) + liftM; -} - -/** - * Reduce one vessel-selection gesture to the layer-owned action it should - * perform. The interaction handler reserves only vessel-record residuals and - * trail picks as no-ops. The interaction wire also reserves sibling-owned - * picks before this reducer so their camera action cannot mutate AIS state. - * - * @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null, - * gesture?: 'click'|'escape'}} input - Current selection plus owned pick. - * @returns {{action: 'none'|'select'|'deselect'}} - */ -export function reduceVesselSelection(input = {}) { - const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi); - const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi); - const gesture = input.gesture || 'click'; - - if (gesture === 'escape') { - return selectedMmsi - ? { action: 'deselect' } - : { action: 'none' }; - } - if (gesture !== 'click') { - return { action: 'none' }; - } - if (pickedMmsi) { - if (pickedMmsi === selectedMmsi) { - return { action: 'none' }; - } - return { action: 'select' }; - } - return selectedMmsi - ? { action: 'deselect' } - : { action: 'none' }; -} - -function normalizeSelectionMmsi(value) { - if (value === null || value === undefined) return null; - const text = String(value).trim(); - return text || null; -} - -/** - * Geoid undulation N at (lat, lon), or null until the grid has loaded. - * @param {number} lat - * @param {number} lon - * @returns {number|null} - */ -function currentGeoidN(lat, lon) { - return _geoidReady ? geoidHeight(lat, lon) : null; -} - -/** @type {Map} `${cssColor}:${variant}` -> chevron SVG data URL */ -const shipIconCache = new Map(); - -const aisLiveVesselsLayer = { - id: 'ais-live-vessels', - name: 'Live AIS Vessels', - icon: '◭', - source: 'AISStream', - updateInterval: REFRESH_MS, - statsRefreshInterval: 1000, - - init(viewer) { - state.viewer = viewer; - ensureCollections(viewer); - _vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false); - installInteraction(viewer); - installRuntime(viewer); - restoreSpriteOrder(viewer); - }, - - enable(viewer) { - const wasEnabled = state.enabled; - state.enabled = true; - if (!wasEnabled) beginAisSession(); - holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) - const activeViewer = viewer || state.viewer; - ensureCollections(activeViewer); - installInteraction(activeViewer); - setVisible(true); - // Height-datum fix: warm the geoid grid once per layer-enable, never - // blocking a poll. The first refresh may land pre-resolve (N = 0), and - // the next is up to 60 s out — so re-floor in place on resolve. A load - // failure leaves N = 0 forever, which is safe: sprites are depth-test- - // free, so vessels stay visible either way. - if (!_geoidReady) { - ensureGeoidReady() - .then(() => { - _geoidReady = true; - refloorVesselRecords(); - }) - .catch(() => { /* grid failed to load — anchors stay at ellipsoid 0 */ }); - } - // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; - // the registry resolver reduces it to the record's mmsi (a string key). - registerPickOwner('ais-live-vessels', (pickedId) => state.vesselMap.has(pickedId)); - restoreSpriteOrderOnEnable('ais', activeViewer); - return loadLivePositions(activeViewer); - }, - - disable() { - state.enabled = false; - invalidateAisSession(); - releaseContinuousRender('ais-vessels'); - unregisterPickOwner('ais-live-vessels'); - setVisible(false); - _vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); - clearVesselInspection(); - destroySelectedVesselTrail(); - removeVesselInteraction(); - if (state.abort) { - state.abort.abort(); - state.abort = null; - } - state.loading = false; - state.loadingLabel = ''; - }, - - update(viewer) { - if (!state.enabled) return Promise.resolve(); - return loadLivePositions(viewer || state.viewer); - }, - - destroy(viewer) { - invalidateAisSession(); - releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) - if (state.abort) state.abort.abort(); - unregisterPickOwner('ais-live-vessels'); - clearVesselInspection(); - destroySelectedVesselTrail(); - if (state.billboardCollection && viewer) { - viewer.scene.primitives.remove(state.billboardCollection); - } - _vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); - _vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false); - removeVesselInteraction(); - if (state.preRenderRemover) { - state.preRenderRemover(); - } - resetState(); - }, - - /** - * Find a vessel by exact MMSI or case-insensitive name substring. - * @param {string|number} query MMSI or partial vessel name. - * @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} - */ - findByQuery(query) { - if (query === null || query === undefined) return null; - const records = state.vesselRecords; - if (!Array.isArray(records) || !records.length) return null; - const q = String(query).trim(); - if (!q) return null; - - let record = null; - if (/^\d+$/.test(q)) { - record = state.vesselMap.get(q) || null; - } - if (!record) { - const lower = q.toLowerCase(); - record = records.find((r) => String(r.name || '').toLowerCase().includes(lower)) || null; - } - if (!record) return null; - - const position = record.billboard?.position || record.position; - if (!position) return null; - return { - mmsi: record.mmsi, - name: record.name, - position, - latitude: record.lat, - longitude: record.lon, - speedKt: record.speed, - course: record.course, - type: record.type, - }; - }, - - /** - * Get vessels within a range of a point, sorted nearest-first. - * @param {Cesium.Cartesian3} centerCartesian Center of the search. - * @param {number} rangeM Max distance in meters (non-finite = unbounded). - * @param {number} [maxCount=25] Maximum entries to return. - * @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>} - */ - getNearby(centerCartesian, rangeM, maxCount = 25) { - const records = state.vesselRecords; - if (!centerCartesian || !Array.isArray(records) || !records.length) return []; - const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity; - const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25; - - const entries = []; - for (const record of records) { - if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue; - const position = record.billboard?.position || record.position; - if (!position) continue; - const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); - if (!Number.isFinite(distanceM) || distanceM > range) continue; - entries.push({ mmsi: record.mmsi, name: record.name, position, distanceM }); - } - entries.sort((a, b) => a.distanceM - b.distanceM); - return entries.slice(0, cap); - }, - - /** - * Get positions of all currently loaded vessels. - * @param {number} [maxCount=800] Maximum entries to return. - * @returns {Array<{ id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number }>} - */ - /** - * Whether this layer still carries a vessel, in O(1). - * - * Mirror of `flights.hasContact`: presence consumers must not infer absence - * from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed. - * A disabled layer keeps its records, so it must decline rather than answer - * from data the user can no longer see. - * @param {string} mmsi Vessel identifier. - * @returns {boolean|null} Presence, or null when the layer is disabled or - * holds no data and therefore cannot answer. - */ - hasContact(mmsi) { - if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0) return null; - if (!mmsi) return false; - return state.vesselMap.has(String(mmsi).trim()); - }, - - getAllPositions(maxCount = 800) { - const result = []; - const records = state.vesselRecords; - if (!Array.isArray(records)) return result; - const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800; - - for (const record of records) { - if (result.length >= cap) break; - const position = record.billboard?.position || record.position; - if (!position) continue; - result.push({ - id: record.mmsi, - label: record.name || record.mmsi, - position, - latitude: record.lat, - longitude: record.lon, - }); - } - return result; - }, - - /** - * Snapshot the layer's in-memory vessel 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} See mapAnalystRecord for the record shape. - */ - getAnalystRecords(maxCount = 2000) { - if (!state.enabled) return []; - const records = state.vesselRecords; - if (!Array.isArray(records) || !records.length) return []; - const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const result = []; - for (const record of records) { - if (result.length >= limit) break; - result.push(mapAnalystRecord(record)); - } - return result; - }, - - /** - * Select a vessel by MMSI via the same path as a map click - * (highlight + HUD update). - * @param {string|number} mmsi Vessel MMSI. - * @returns {boolean} True if a matching vessel was selected. - */ - selectById(mmsi) { - if (mmsi === null || mmsi === undefined) return false; - const target = String(mmsi).trim(); - if (!target) return false; - const record = state.vesselMap.get(target); - if (!record) return false; - selectVessel(record); - return true; - }, - - /** - * Clear the current vessel selection and reset the HUD readout. - * @returns {boolean} Always true. - */ - clearSelection() { - clearVesselInspection(); - return true; - }, - - /** - * Get info about the currently selected vessel. - * @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} - */ - getSelectedInfo() { - const record = state.selectedRecord; - if (!record) return null; - return { - mmsi: record.mmsi, - name: record.name, - latitude: record.lat, - longitude: record.lon, - speedKt: record.speed, - course: record.course, - type: record.type, - }; - }, - - /** - * Return a subset of vessels for the universal detection overlay. - * Deterministic stride sampling distributes selections evenly across the - * current record list while honoring the overlay's per-layer budget. - * @param {Object} [options={}] - Options from the detection system. - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). - * @param {number} [options.seed] - Seed offset for stride sampling. - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!state.enabled || !state.billboardCollection || !state.billboardCollection.show) return []; - const records = state.vesselRecords; - if (!Array.isArray(records) || !records.length) return []; - - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : records.length; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Deterministic stride: evenly space selections across the record list - const stride = Math.max(1, Math.ceil(records.length / maxCount)); - const start = seed % stride; - - const selected = state.selectedRecord; - const result = []; - for (let idx = 0; idx < records.length; idx += 1) { - if (((idx - start) % stride) !== 0) continue; - const record = records[idx]; - if (record.billboard && !record.billboard.show) continue; - const position = record.billboard?.position || record.position; - if (!position) continue; - result.push({ - position, - sourceId: record.mmsi, - id: record.name || record.mmsi || 'VESSEL', - type: 'SEA', - skipLabel: record === selected, - klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, - metric: formatKnots(record.speed), // record.speed is knots - }); - if (result.length >= maxCount) break; - } - return result; - }, - - ...(FOCUS_EVIDENCE_DEV ? { - __focusEvidence: Object.freeze({ - setVessels: _setFocusEvidenceVessels, - snapshot: _focusEvidenceVesselSnapshot, - }), - } : {}), - - getStats() { - const waitingForFirstPosition = state.firstConnectPhase === 'loading'; - return { - count: state.count, - lastUpdate: state.lastUpdate, - loading: state.loading || waitingForFirstPosition, - loadingLabel: waitingForFirstPosition - ? AIS_FIRST_CONNECT_LABEL - : state.loadingLabel, - error: state.error, - stale: state.stale, - status: state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined, - transportStatus: state.transportStatus, - lastMessageAt: state.lastMessageAt, - rawRowCount: state.rawRowCount, - acceptedRowCount: state.acceptedRowCount, - // Same chip affordance the flights layer uses: when the server is - // backing off, say how long until the next attempt instead of leaving - // the user to guess whether anything is still happening. - retryInSec: aisRetryInSec(), - }; - }, -}; - -const state = { - viewer: null, - enabled: false, - loading: false, - loaded: false, - stale: false, - error: null, - loadingLabel: '', - lastUpdate: null, - count: 0, - newestPositionAt: null, - transportStatus: null, - /** Server epoch-ms of the next reconnect attempt while the feed is degraded. */ - nextAttemptAt: null, - lastMessageAt: null, - rawRowCount: 0, - acceptedRowCount: 0, - /** Monotonic enable/reset owner for requests and first-connect timers. */ - sessionId: 0, - /** @type {'idle'|'loading'|'ready'|'unavailable'} */ - firstConnectPhase: 'idle', - firstConnectStartedAt: null, - firstConnectDeadline: null, - firstConnectTimer: null, - abort: null, - billboardCollection: null, - /** @type {Array} Flat render list: keyed records + unkeyed records */ - vesselRecords: [], - /** @type {Map} MMSI -> vessel record (identity across refreshes) */ - vesselMap: new Map(), - /** @type {Array} Records with no MMSI — rebuilt fresh each refresh */ - unkeyedRecords: [], - clickHandler: null, - /** Exact EventTarget currently holding the Escape listener. */ - keyTarget: null, - /** Exact callback registered on keyTarget. */ - keydownHandler: null, - /** Cesium trackedEntityChanged listener disposer. */ - trackedEntityRemover: null, - /** Test-only factory used to exercise enable-time interaction installation. */ - interactionHandlerFactory: null, - /** Test-only key target paired with interactionHandlerFactory. */ - interactionKeyTarget: null, - preRenderRemover: null, - lastVisibilityUpdate: 0, - lastFocusUpdate: 0, - /** Sprites whose animated emphasis remains outside the 1.0 deadband. */ - activeFocusCount: 0, - activeLabelCount: 0, - selectedRecord: null, - /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ - trail: null, - /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */ - trailPositions: [], - /** @type {string|null} MMSI that owns the active selected-vessel trail. */ - trailMmsi: null, - /** @type {number} Monotonic token — invalidates in-flight backfill responses */ - trailBackfillToken: 0, -}; - -/** Replace live AIS rows through the production reconciliation path (DEV only). */ -function _setFocusEvidenceVessels(rows = []) { - if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) { - return { ok: false, count: 0 }; - } - clearVesselInspection(); - reconcileVessels(state.viewer, Array.isArray(rows) ? rows : []); - state.count = state.vesselRecords.length; - state.loaded = true; - state.error = null; - state.stale = false; - state.lastUpdate = Date.now(); - state.transportStatus = 'synthetic'; - state.lastMessageAt = null; - state.rawRowCount = Array.isArray(rows) ? rows.length : 0; - state.acceptedRowCount = state.count; - return { ok: true, count: state.count }; -} - -/** JSON-safe vessel alpha/position snapshot for the evidence report. */ -function _focusEvidenceVesselSnapshot() { - if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; - return state.vesselRecords.map((record) => { - const bb = record.billboard; - const screen = bb?.position - ? Cesium.SceneTransforms.worldToWindowCoordinates(state.viewer.scene, bb.position) - : null; - return { - id: record.mmsi, - show: bb?.show === true, - alpha: bb?.color?.alpha ?? null, - x: screen?.x ?? null, - y: screen?.y ?? null, - }; - }); -} - +export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js'; +export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError; +export const classifyAisFeedSnapshot = + aisLiveVesselsLayer.classifyAisFeedSnapshot; +export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord; +export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM; +export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection; +export const applyVesselFocusDeemphasis = + aisLiveVesselsLayer.applyVesselFocusDeemphasis; +export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard; +export const buildSelectedVesselCard = + aisLiveVesselsLayer.buildSelectedVesselCard; +export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated; +export const _bindVesselInteractionForTest = + aisLiveVesselsLayer.testing._bindVesselInteractionForTest; +export const _setVesselStateForTest = + aisLiveVesselsLayer.testing._setVesselStateForTest; +export const _setVesselOverlayHostForTest = + aisLiveVesselsLayer.testing._setVesselOverlayHostForTest; +export const _updateVesselCardsForTest = + aisLiveVesselsLayer.testing._updateVesselCardsForTest; +export const _reconcileVesselsForTest = + aisLiveVesselsLayer.testing._reconcileVesselsForTest; +export const _applyAisFeedSnapshotForTest = + aisLiveVesselsLayer.testing._applyAisFeedSnapshotForTest; +export const _loadLivePositionsForTest = + aisLiveVesselsLayer.testing._loadLivePositionsForTest; +export const _beginAisSessionForTest = + aisLiveVesselsLayer.testing._beginAisSessionForTest; +export const _setAisRuntimeForTest = + aisLiveVesselsLayer.testing._setAisRuntimeForTest; +export const _getVesselFeedStateForTest = + aisLiveVesselsLayer.testing._getVesselFeedStateForTest; +export const _getVesselStateForTest = + aisLiveVesselsLayer.testing._getVesselStateForTest; export default aisLiveVesselsLayer; - -function clearFirstConnectTimer() { - if (state.firstConnectTimer === null) return; - _aisRuntime.clearTimeout(state.firstConnectTimer); - state.firstConnectTimer = null; -} - -function invalidateAisSession() { - clearFirstConnectTimer(); - state.sessionId = ++_aisSessionSequence; - state.firstConnectPhase = 'idle'; - state.firstConnectStartedAt = null; - state.firstConnectDeadline = null; -} - -function beginAisSession() { - clearFirstConnectTimer(); - const sessionId = ++_aisSessionSequence; - const startedAt = _aisRuntime.now(); - state.sessionId = sessionId; - state.firstConnectPhase = 'loading'; - state.firstConnectStartedAt = startedAt; - state.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS; - state.error = null; - state.loadingLabel = AIS_FIRST_CONNECT_LABEL; - scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS); -} - -function scheduleFirstConnectExpiry(sessionId, delayMs) { - state.firstConnectTimer = _aisRuntime.setTimeout(() => { - if ( - !state.enabled - || state.sessionId !== sessionId - || state.firstConnectPhase !== 'loading' - ) return; - const remainingMs = state.firstConnectDeadline - _aisRuntime.now(); - if (remainingMs > 0) { - scheduleFirstConnectExpiry(sessionId, remainingMs); - return; - } - state.firstConnectTimer = null; - state.firstConnectPhase = 'unavailable'; - state.loadingLabel = ''; - state.error = state.lastMessageAt - ? 'awaiting usable AIS positions…' - : 'awaiting first AIS message…'; - state.stale = state.count > 0; - }, delayMs); -} - -function settleFirstConnectPhase(phase) { - clearFirstConnectTimer(); - state.firstConnectPhase = phase; - state.loadingLabel = ''; -} - -function isGraceEligibleTransport(status) { - return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting'; -} - -function isDefinitiveTransportFailure(status) { - return Boolean(status) && !isGraceEligibleTransport(status); -} - -function markAisUnavailable(reason) { - settleFirstConnectPhase('unavailable'); - state.error = reason || 'AIS live load failed'; - state.stale = state.count > 0; -} - -async function loadLivePositions(viewer) { - if (!viewer || state.loading) return; - state.loading = true; - state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...'; - const requestController = new AbortController(); - const requestSessionId = state.sessionId; - state.abort = requestController; - - try { - const url = liveApiUrl(); - // Combine the layer's teardown-abort with a hard timeout so a hung upstream - // can't wedge the poll indefinitely (parity with the track fetch + flights). - const signal = typeof AbortSignal.any === 'function' - ? AbortSignal.any([requestController.signal, AbortSignal.timeout(10000)]) - : requestController.signal; - const response = await fetch(url, { - signal, - cache: 'no-store', - }); - if (!ownsAisRequest(requestController, requestSessionId)) return; - if (!response.ok) { - // The 503 key-absent / 502 stream-error bodies still carry {status,error}. - // Prefer a clean surfaced reason over a cryptic "AIS live HTTP 503". - let reason = `AIS live HTTP ${response.status}`; - try { - const errPayload = await response.json(); - if (!ownsAisRequest(requestController, requestSessionId)) return; - reason = deriveAisFeedError(errPayload, 0) - || (typeof errPayload?.error === 'string' && errPayload.error.trim()) || reason; - } catch { /* non-JSON body — keep the HTTP status reason */ } - throw new Error(reason); - } - - const payload = await response.json(); - if (!ownsAisRequest(requestController, requestSessionId)) return; - applyAisFeedSnapshot(viewer, payload); - } catch (error) { - if (ownsAisRequest(requestController, requestSessionId) && error?.name !== 'AbortError') { - markAisUnavailable(error?.message || 'AIS live load failed'); - console.warn('[Data:ais-live-vessels]', state.error, error); - } - } finally { - if (state.abort === requestController && state.sessionId === requestSessionId) { - state.loading = false; - state.loadingLabel = state.firstConnectPhase === 'loading' - ? AIS_FIRST_CONNECT_LABEL - : ''; - state.abort = null; - } - } -} - -/** True while a request still owns this enabled layer lifecycle. */ -function ownsAisRequest(controller, sessionId) { - return state.enabled - && state.sessionId === sessionId - && state.abort === controller - && !controller.signal.aborted; -} - -/** Apply a classified snapshot while preserving warm state on zero accepted rows. */ -function applyAisFeedSnapshot(viewer, payload) { - const snapshot = classifyAisFeedSnapshot(payload); - state.loaded = true; - state.loadingLabel = ''; - state.transportStatus = snapshot.transportStatus; - state.nextAttemptAt = Number(payload?.nextAttemptAt) || null; - state.lastMessageAt = snapshot.lastMessageAt; - state.rawRowCount = snapshot.rawRowCount; - state.acceptedRowCount = snapshot.acceptedRowCount; - - if (snapshot.acceptedRowCount === 0) { - state.count = state.vesselRecords.length; - state.stale = state.count > 0 || Boolean(payload?.refreshing); - if (isDefinitiveTransportFailure(snapshot.transportStatus)) { - markAisUnavailable(snapshot.error); - return { reconciled: false, ...snapshot }; - } - if ( - state.firstConnectPhase === 'loading' - && isGraceEligibleTransport(snapshot.transportStatus) - ) { - state.error = null; - state.loadingLabel = AIS_FIRST_CONNECT_LABEL; - return { reconciled: false, ...snapshot }; - } - if (state.firstConnectPhase === 'loading') { - markAisUnavailable(snapshot.error); - return { reconciled: false, ...snapshot }; - } - state.error = snapshot.error; - return { reconciled: false, ...snapshot }; - } - - settleFirstConnectPhase('ready'); - reconcileVessels(viewer, snapshot.acceptedRows); - state.count = state.vesselRecords.length; - state.stale = Boolean(payload?.refreshing); - state.newestPositionAt = payload?.newestPositionAt || null; - // Not unconditionally null: a degraded feed keeps its reason even though the - // cached vessels are still drawable, so the chip cannot go quiet on an - // outage the user is still looking at. - state.error = snapshot.error; - state.lastUpdate = _aisRuntime.now(); - return { reconciled: true, ...snapshot }; -} - -function liveApiUrl() { - const base = import.meta.env?.VITE_AIS_LIVE_API_URL || DEFAULT_API_URL; - const url = new URL(base, window.location.origin); - url.searchParams.set('maxRows', String(renderRowLimit())); - return url.toString(); -} - -function renderRowLimit() { - const configured = Number(import.meta.env?.VITE_AIS_LIVE_MAX_ROWS); - if (Number.isFinite(configured) && configured > 0) { - return Math.max(500, Math.min(50000, Math.round(configured))); - } - return DEFAULT_RENDER_ROWS; -} - -function labelRowLimit() { - const configured = Number(import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS); - if (Number.isFinite(configured) && configured >= 0) { - return Math.max(0, Math.min(renderRowLimit(), Math.round(configured))); - } - return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit()); -} - -function ensureCollections(viewer) { - if (!viewer || state.billboardCollection) return; - state.billboardCollection = new Cesium.BillboardCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - state.billboardCollection.show = state.enabled; - viewer.scene.primitives.add(state.billboardCollection); - registerSpriteCollection('ais', state.billboardCollection); -} - -/** - * Reconcile the incoming AIS rows against the MMSI-keyed record map. - * Existing records are updated in place (position/heading/label) so identity - * and selection survive refreshes; new vessels are added; vanished vessels are - * removed — except the selected vessel, which is pinned for up to - * SELECTED_PIN_REFRESHES consecutive misses with a stale HUD readout. - * Rows without an MMSI are rendered unkeyed and rebuilt fresh each refresh. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - * @param {Array} rows - Raw AIS rows from the live API. - */ -function reconcileVessels(viewer, rows) { - ensureCollections(viewer); - - // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them. - for (const record of state.unkeyedRecords) { - removeRecordPrimitives(record); - } - state.unkeyedRecords = []; - - const occluder = makeOccluder(); - const seen = new Set(); - for (let index = 0; index < rows.length; index += 1) { - const next = normalizeVessel(rows[index]); - if (!next) continue; - - if (!next.mmsi) { - addRecordPrimitives(next, occluder); - state.unkeyedRecords.push(next); - continue; - } - if (seen.has(next.mmsi)) continue; // defensive: dedupe payload rows - seen.add(next.mmsi); - - const existing = state.vesselMap.get(next.mmsi); - if (existing) { - updateRecordInPlace(existing, next); - } else { - addRecordPrimitives(next, occluder); - state.vesselMap.set(next.mmsi, next); - } - } - - // Remove vanished vessels, pinning the selected one for a few refreshes. - for (const [mmsi, record] of state.vesselMap) { - if (seen.has(mmsi)) continue; - if (record === state.selectedRecord) { - record.missedRefreshes = (record.missedRefreshes || 0) + 1; - if (record.missedRefreshes <= SELECTED_PIN_REFRESHES) { - updateSelectedVesselHud(record); // re-render with STALE marker - continue; - } - // Aged out of the feed after exhausting its pin — not a deselect. - clearVesselInspection({ evicted: true }); - } - removeRecordPrimitives(record); - state.vesselMap.delete(mmsi); - // Defensive lifecycle closure: a trail may outlive selection state during - // asynchronous handoff/refresh ordering, but never its owning record. - if (state.trailMmsi === mmsi) clearSelectedVesselTrail(); - } - - state.vesselRecords = [...state.vesselMap.values(), ...state.unkeyedRecords]; - state.lastVisibilityUpdate = 0; - updateVisibility(true); -} - -/** - * Create the billboard primitive for a freshly added vessel record. Map labels - * are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no - * per-record label primitive exists anymore. - * @param {Object} record - Normalized vessel record. - * @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility. - */ -function addRecordPrimitives(record, occluder) { - const visible = state.enabled && isVisible(record.surfacePosition, occluder); - record.billboard = state.billboardCollection.add({ - position: record.position, - show: visible, - image: shipIcon(record, false), - scale: shipScale(record), - // Screen-projected rotation lands on the next visibility/rotation pass. - rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Locked height-datum principle #2: contacts are ALWAYS visible — - // depth-test-free sprites; the EllipsoidalOccluder handles the far side. - // (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at - // the geoid still clips out behind local tide/mesh noise.) - disableDepthTestDistance: Number.POSITIVE_INFINITY, - id: record, - }); -} - -/** - * Update an existing record (and its primitives) from a freshly normalized row, - * preserving object identity so selection and the click-pick id stay valid. - * Billboard image is only reassigned when the resolved icon actually changes - * (type recolor) to avoid thousands of redundant texture lookups per refresh. - * @param {Object} record - Existing vessel record in state.vesselMap. - * @param {Object} next - Freshly normalized record for the same MMSI. - */ -function updateRecordInPlace(record, next) { - const selected = record === state.selectedRecord; - const prevIcon = shipIcon(record, selected); - - record.lat = next.lat; - record.lon = next.lon; - record.name = next.name; - record.imo = next.imo; - record.type = next.type; - record.destination = next.destination; - record.speed = next.speed; - record.course = next.course; - record.heading = next.heading; - record.lastPositionUtc = next.lastPositionUtc; - record.lastPositionEpoch = next.lastPositionEpoch; - record.position = next.position; - record.surfacePosition = next.surfacePosition; - record.normal = next.normal; - record.missedRefreshes = 0; - - if (record.billboard) { - record.billboard.position = record.position; - // Rotation is owned by the projected-rotation pass (updateVisibility). - record.billboard.scale = shipScale(record) * (selected ? 1.2 : 1); - const nextIcon = shipIcon(record, selected); - if (nextIcon !== prevIcon) { - record.billboard.image = nextIcon; - } - } - if (record.mmsi === state.trailMmsi) { - appendSelectedVesselTrailFix(record); - } - if (selected) { - updateSelectedVesselHud(record); - registerSelectedContext(record); - } -} - -/** - * Remove a record's billboard primitive from its collection. - * @param {Object} record - Vessel record to tear down. - */ -function removeRecordPrimitives(record) { - if (!record) return; - if (record.billboard && state.billboardCollection) { - forgetSpriteFocus(record.billboard); - state.billboardCollection.remove(record.billboard); - } - record.billboard = null; -} - -function normalizeVessel(row) { - const lat = Number(row.lat); - const lon = Number(row.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - // Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the - // ellipsoid — at height 0 everything that projects record.position (clicks, - // detection brackets, cards, getNearby) points up to ~45 m under the water. - const heightM = vesselDatumHeightM(currentGeoidN(lat, lon), VESSEL_LIFT_M); - const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM); - // Surface normal at this position — used as alignedAxis so billboard - // rotation operates in the local tangent plane (true world heading) - const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position, new Cesium.Cartesian3()); - return { - lat, - lon, - name: String(row.name || row.input_name || row.mmsi || row.input_identifier || 'VESSEL'), - mmsi: String(row.mmsi || row.input_identifier || '').trim(), - imo: String(row.imo || ''), - type: String(row.type_specific || row.type || ''), - destination: String(row.destination || ''), - speed: finiteNumber(row.speed), - course: finiteNumber(row.course), - heading: finiteNumber(row.heading), - lastPositionUtc: String(row.last_position_UTC || ''), - lastPositionEpoch: finiteNumber(row.last_position_epoch), - position, - // Ellipsoid-surface point (height 0) — feeds ONLY the horizon occluder, - // which tests against the WGS84 ellipsoid; keep it off the sea datum. - surfacePosition: Cesium.Cartesian3.fromDegrees(lon, lat, 0), - normal, - missedRefreshes: 0, - billboard: null, - }; -} - -function finiteNumber(value) { - if (value === null || value === undefined || value === '') return null; - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function shipScale(record) { - const speed = Number(record.speed || 0); - if (speed >= 18) return 0.78; - if (speed >= 8) return 0.68; - return 0.6; -} - -/** - * Best available real-world direction of travel for a vessel, degrees - * clockwise from north (true heading preferred, course-over-ground fallback). - * Screen rotation is computed from this by the shared projected-rotation - * pass in updateVisibility — never directly from the compass value. - * @param {Object} record - Vessel record. - * @returns {number} Course in degrees (0 when unknown). - */ -function vesselCourseDeg(record) { - const direction = record.heading ?? record.course; - return Number.isFinite(direction) ? direction : 0; -} - -/** - * Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel. - * The shape points north (up) so billboard rotation maps directly to heading. - * One icon is generated per color+variant and reused across all billboards. - * @param {Object} record - Vessel record (drives per-type tint). - * @param {boolean} selected - True for the white/brighter selected variant. - * @returns {string} SVG data URL. - */ -function shipIcon(record, selected) { - const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type); - const key = `${cssColor}:${selected ? 'selected' : 'normal'}`; - if (shipIconCache.has(key)) return shipIconCache.get(key); - - const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)'; - const strokeWidth = selected ? 1.1 : 0.7; - const svg = ` - - - - `; - const icon = 'data:image/svg+xml;base64,' + btoa(svg); - shipIconCache.set(key, icon); - return icon; -} - -function installRuntime(viewer) { - if (state.preRenderRemover || !viewer) return; - state.preRenderRemover = viewer.scene.preRender.addEventListener(() => updateVisibility()); -} - -function updateVisibility(force = false) { - if (!state.enabled) return; - const now = focusNowMs(performance.now()); - const focusTarget = getFocusTarget(); - const regularPass = force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS; - const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount) - && (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); - if (!regularPass && !focusPass) return; - if (regularPass) state.lastVisibilityUpdate = now; - if (focusPass) state.lastFocusUpdate = now; - if (!state.vesselRecords.length) { - // No records — flush any lingering card entries (vanished-feed case). - if (regularPass) updateClusteredLabels([]); - if (focusPass) state.activeFocusCount = 0; - return; - } - - const scene = state.viewer?.scene; - const camera = state.viewer?.camera; - if (regularPass) { - // Candidate construction stays on the original 800 ms selector cadence. - // The 80 ms focus-only pass below never allocates label candidates. - const poseSig = camera ? cameraPoseSignature(camera) : ''; - const doRotations = force || poseSig !== _lastCamPoseSig; - if (doRotations) _lastCamPoseSig = poseSig; - const occluder = makeOccluder(); - const labelCandidates = []; - for (const record of state.vesselRecords) { - const visible = isVisible(record.surfacePosition, occluder); - if (record.billboard) { - record.billboard.show = visible; - if (visible && doRotations && scene) { - const rot = screenProjectedRotation( - scene, record.position, vesselCourseDeg(record), record.billboard.rotation - ); - if (rot !== null && Math.abs(rot - record.billboard.rotation) > 0.002) { - record.billboard.rotation = rot; - } - } - } - if (visible) labelCandidates.push(record); - } - updateClusteredLabels(labelCandidates); - } - if (focusPass && scene && camera) { - const result = applyVesselFocusDeemphasis({ - records: state.vesselRecords, - target: focusTarget, - previousActiveCount: state.activeFocusCount, - nowMs: now, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchFocusScreen) - ), - cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - }); - state.activeFocusCount = result.activeCount; - } -} - -/** - * Apply focus alpha to vessel sprites. Kept as a production wire seam so the - * animation/deadband contract can be tested without constructing WebGL. - * @param {object} input - * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} - */ -export function applyVesselFocusDeemphasis({ - records, - target, - previousActiveCount = 0, - nowMs, - screenPositionFor, - cameraDistanceFor, - params, -}) { - if (!focusPassIsNeeded(target, previousActiveCount)) { - return { writes: 0, transitioning: false, activeCount: 0, ran: false }; - } - let writes = 0; - let transitioning = false; - let activeCount = 0; - for (const record of records || []) { - const bb = record?.billboard; - const position = bb?.position || record?.position; - if (!bb || !position) continue; - const focus = advanceSpriteFocus(bb, { - // Hidden/far-side sprites still finish any pending release so the active - // count remains truthful and they cannot reappear with stale dim alpha. - screenPosition: bb.show === false ? null : screenPositionFor(position), - cameraDistance: cameraDistanceFor(position), - nowMs, - target, - params, - // Vessel artwork is 32 px before billboard scale. Including the - // ambient chevron's own rendered extent prevents edge-overlap misses. - spriteHalfWidthPx: (bb.width || 32) * (bb.scale || 1) * 0.5, - spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5, - }); - transitioning ||= focus.transitioning; - if (focus.active) activeCount += 1; - if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) { - // Narrow always-visible amendment: the ship chevron remains present at - // the non-zero floor while it competes with the tracked target. Preserve - // the billboard's existing base RGB, matching the other layer patterns, - // rather than repainting every chevron from a hard-coded WHITE base. - const baseColor = bb.color || Cesium.Color.WHITE; - bb.color = baseColor.withAlpha(focus.factor); - writes += 1; - } - } - return { writes, transitioning, activeCount, ran: true }; -} - -function makeOccluder() { - const cameraPosition = state.viewer?.camera?.positionWC; - if (!cameraPosition) return null; - return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPosition); -} - -function isVisible(surfacePosition, occluder) { - if (!surfacePosition || !occluder) return true; - return occluder.isPointVisible(surfacePosition); -} - -/** - * Source selector for the shared world-overlay pipeline: the - * grid declutter picks which vessels get cards — one winner per screen-space - * grid cell, priority-ranked, capped — and publishes presentation entries to - * the host, which owns projection, final placement, fade and paint. Runs on the - * throttled visibility pass and forced refreshes, never per frame. The - * selected vessel always gets its full-detail card, even when horizon-culled - * from the ambient candidates; its protected entry bypasses ambient quotas. - * @param {Array} records - Horizon-visible vessel records. - */ -function updateClusteredLabels(records) { - const viewer = state.viewer; - const scene = viewer?.scene; - const selected = state.selectedRecord; - const entries = selected ? [buildSelectedVesselCard(selected)] : []; - const maxLabels = labelRowLimit(); - - if (!scene || !records.length || maxLabels <= 0) { - state.activeLabelCount = entries.length; - publishVesselOverlayEntries(entries); - return; - } - - const cells = new Map(); - for (const record of records) { - if (record === selected) continue; - const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, record.position); - if (!screen) continue; - const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`; - const candidate = { record, score: labelPriority(record, selected), x: screen.x, y: screen.y }; - const existing = cells.get(key); - if (!existing || candidate.score > existing.score) { - cells.set(key, candidate); - } - } - - // Greedy min-separation pass over the priority-ranked cell winners: the - // selected card's anchor seeds the accepted set so ambient cards keep clear. - const accepted = []; - if (selected) { - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, selected.billboard?.position || selected.position - ); - if (screen) accepted.push({ x: screen.x, y: screen.y }); - } - const ranked = [...cells.values()].sort((a, b) => b.score - a.score); - for (const candidate of ranked) { - if (entries.length >= maxLabels) break; - if (!cardScreenSeparated(accepted, candidate, CARD_MIN_SEP_PX)) continue; - accepted.push({ x: candidate.x, y: candidate.y }); - entries.push(buildVesselCard(candidate.record)); - } - state.activeLabelCount = entries.length; - publishVesselOverlayEntries(entries); -} - -/** - * Publish a complete, bounded source snapshot to the shared host. The source - * selector remains authoritative for the 118 px grid and 150 px separation; - * the host then composes this demand with sibling ambient-card sources. - * @param {Object[]} entries Formatted vessel card entries. - */ -function publishVesselOverlayEntries(entries) { - const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas; - const width = Number(canvas?.clientWidth) || 0; - const height = Number(canvas?.clientHeight) || 0; - const ambientLimit = vesselOverlayCohortLimit(width, height, labelRowLimit()); - _vesselOverlayHost.setEntries( - VESSEL_OVERLAY_SOURCE_ID, - entries.map((entry) => { - const card = applyVesselOverlayPolicy(entry, VESSEL_CARD_FADE_DISTANCE_M); - if (!card.interactive) return card; - const mmsi = String(card.id || '').startsWith('vessel:') - ? card.id.slice('vessel:'.length) - : ''; - return { - ...card, - accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`, - activate: () => { - const record = state.vesselMap.get(mmsi); - if (!record) return false; - selectAndFocusVessel(record); - return true; - }, - }; - }), - { - cohortLimit: Math.max(1, ambientLimit), - collisionCapacity: ambientLimit, - moving: false, - }, - ); -} - -function labelPriority(record, selected) { - if (record === selected) return 100000; - let score = 0; - if (hasUsefulName(record)) score += 1000; - if (record.speed !== null) score += Math.min(400, Math.max(0, record.speed) * 20); - if (record.heading !== null || record.course !== null) score += 80; - if (record.type) score += 40; - return score; -} - -function hasUsefulName(record) { - const text = String(record.name || '').trim(); - return Boolean(text && text !== 'VESSEL' && !/^MMSI\s*\d+$/i.test(text) && text !== record.mmsi); -} - -function installInteraction(viewer) { - if (state.clickHandler || !viewer) return; - const handler = state.interactionHandlerFactory - ? state.interactionHandlerFactory(viewer) - : new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindVesselInteraction(viewer, handler, state.interactionKeyTarget || document); -} - -function bindVesselInteraction(viewer, handler, keyTarget) { - state.clickHandler = handler; - handler.setInputAction((click) => { - if (!state.enabled) return; - const picked = viewer.scene.pick(click.position); - const pickedId = resolvePickId(picked); - let record = pickedId ? state.vesselMap.get(pickedId) : null; - const rawId = picked?.id ?? picked?.primitive?.id; - const ownRecordPick = rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi'); - - // An own-layer record without a live map key is a strict no-op (FB-1 - // residual). Trails carry no layer identity and hug their contacts, so any - // `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling - // unowned scene picks dismiss the current vessel inspection. - if (ownRecordPick && (!pickedId || !record)) return; - if (pickedId && !record && String(pickedId).startsWith('gev-trail:')) return; - - // A sibling layer already owns this click. Preserve the current vessel - // selection and do not compete with its camera command. - if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return; - - // Cards are painted on a pointer-events:none canvas, so the scene pick is - // usually terrain behind the card. Resolve against the host's current - // actionable hit rectangles before treating the click as empty space. - const cardHit = !record - ? _vesselOverlayHost.hitTest?.(click.position?.x, click.position?.y, { - sourceId: VESSEL_OVERLAY_SOURCE_ID, - }) - : null; - if (!record && cardHit) { - const mmsi = String(cardHit.entryId || '').startsWith('vessel:') - ? cardHit.entryId.slice('vessel:'.length) - : null; - record = mmsi ? state.vesselMap.get(mmsi) || null : null; - // A stale card id is not empty terrain and must not clear a newer - // selection. The next paint will evict its hit rectangle. - if (!record) return; - } - - if (record) { - // A valid sprite or card click always transfers the camera exactly once, - // including a second click on the already-selected vessel. - selectAndFocusVessel(record); - } else { - const transition = reduceVesselSelection({ - selectedMmsi: state.selectedRecord?.mmsi, - pickedMmsi: null, - gesture: 'click', - }); - if (transition.action === 'deselect') clearVesselInspection(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - state.keyTarget = keyTarget; - state.keydownHandler = onVesselKeyDown; - keyTarget.addEventListener('keydown', state.keydownHandler); - // Vessels never set viewer.trackedEntity, so any new tracked entity belongs - // to another layer and takes interaction ownership of the scene. - state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(() => { - if (viewer.trackedEntity && state.selectedRecord) clearVesselInspection(); - }); -} - -/** Select one live vessel and request one UI-owned camera transfer. */ -function selectAndFocusVessel(record) { - if (!record?.mmsi) return false; - const transition = reduceVesselSelection({ - selectedMmsi: state.selectedRecord?.mmsi, - pickedMmsi: record.mmsi, - gesture: 'click', - }); - if (transition.action === 'select') selectVessel(record); - requestWorldFocus({ - kind: 'vessel', - id: record.mmsi, - label: record.name || record.mmsi, - position: record.billboard?.position || record.position, - }); - return true; -} - -function removeVesselInteraction() { - if (state.clickHandler) { - state.clickHandler.destroy(); - state.clickHandler = null; - } - if (state.keyTarget && state.keydownHandler) { - state.keyTarget.removeEventListener('keydown', state.keydownHandler); - } - state.keyTarget = null; - state.keydownHandler = null; - if (state.trackedEntityRemover) { - state.trackedEntityRemover(); - state.trackedEntityRemover = null; - } -} - -function onVesselKeyDown(event) { - if (!state.enabled || event.key !== 'Escape') return; - const transition = reduceVesselSelection({ - selectedMmsi: state.selectedRecord?.mmsi, - gesture: 'escape', - }); - if (transition.action === 'deselect') { - clearVesselInspection(); - } -} - -function selectVessel(record) { - if (!record?.mmsi) return; - const reuseTrail = state.trailMmsi === record.mmsi; - clearSelection({ preserveTrail: reuseTrail }); - state.selectedRecord = record; - record.missedRefreshes = 0; - if (record.billboard) { - record.billboard.image = shipIcon(record, true); - record.billboard.scale = shipScale(record) * 1.2; - } - // Rebuild the card set immediately so the full-detail card appears on the - // click, not up to VISIBILITY_UPDATE_MS later. - updateVisibility(true); - updateSelectedVesselHud(record); - if (registerSelectedContext(record)) { - selectEntityContext(record); - } - // Track-history trail (PRD F3/F4): seed with the current position + async - // backfill from the server-side per-MMSI ring buffer. - if (reuseTrail) { - appendSelectedVesselTrailFix(record); - } else { - startSelectedVesselTrail(record); - } -} - -/** - * Build a slightly lifted trail vertex for a vessel record — raised - * TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor) - * to avoid z-fighting. - * @param {Object} record - Vessel record with lat/lon. - * @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix. - */ -function vesselTrailPosition(record) { - if (!Number.isFinite(record?.lat) || !Number.isFinite(record?.lon)) return null; - const heightM = vesselDatumHeightM(currentGeoidN(record.lat, record.lon), TRAIL_HEIGHT_M); - return Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM); -} - -/** - * One-shot datum re-lift when the geoid grid warms mid-session: the first - * refresh can land before ensureGeoidReady() resolves (anchors at N = 0) and - * the next refresh is up to REFRESH_MS out — re-derive every record's - * position in place so chevrons/labels snap to the sea surface as soon as N - * is known. (A selected-vessel trail cannot exist that early — selection - * needs a rendered pick — so trail vertices are not revisited.) - */ -function refloorVesselRecords() { - if (!state.vesselRecords.length) return; - for (const record of state.vesselRecords) { - if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue; - const heightM = vesselDatumHeightM(currentGeoidN(record.lat, record.lon), VESSEL_LIFT_M); - record.position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM); - if (record.billboard) record.billboard.position = record.position; - } -} - -/** - * Start (or restart) the selected vessel's trail: seed with the current - * position, render, then fire-and-forget the server ring-buffer backfill. - * @param {Object} record - Freshly selected vessel record. - */ -function startSelectedVesselTrail(record) { - state.trailBackfillToken += 1; - state.trailMmsi = record.mmsi; - state.trailPositions = []; - const current = vesselTrailPosition(record); - if (current) state.trailPositions.push(current); - if (!state.trail && state.viewer) { - state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 }); - } - if (state.trail) state.trail.setPositions(state.trailPositions); - backfillVesselTrail(record.mmsi, state.trailBackfillToken); -} - -/** - * Fire-and-forget backfill from the server-side per-MMSI ring buffer - * (PRD F3 — "recent path" since server boot, not voyage history). Older - * samples are spliced AHEAD of the live accumulation, capped at - * TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed) - * silently keeps the live-only trail. - * @param {string} mmsi - MMSI of the selected vessel. - * @param {number} token - Backfill token captured at request time. - * @returns {Promise} - */ -async function backfillVesselTrail(mmsi, token) { - let samples = null; - try { - const response = await fetch('/api/ais-live/track?mmsi=' + encodeURIComponent(mmsi), { - signal: AbortSignal.timeout(8000), - }); - if (!response.ok) return; - const payload = await response.json(); - samples = Array.isArray(payload?.samples) ? payload.samples : null; - } catch { - return; // silent — keep the live-accumulated trail - } - if (!samples || token !== state.trailBackfillToken) return; - if (state.trailMmsi !== mmsi) return; - - const older = []; - for (const sample of samples) { - const lat = Number(sample?.lat); - const lon = Number(sample?.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; - // Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as - // the live vertices so the spliced trail is height-continuous. - const heightM = vesselDatumHeightM(currentGeoidN(lat, lon), TRAIL_HEIGHT_M); - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, heightM)); - } - if (!older.length) return; - - state.trailPositions = older.concat(state.trailPositions); - if (state.trailPositions.length > TRAIL_MAX_POINTS) { - state.trailPositions = state.trailPositions.slice(state.trailPositions.length - TRAIL_MAX_POINTS); - } - if (state.trail) state.trail.setPositions(state.trailPositions); -} - -/** - * Append the selected vessel's refreshed position to its trail when it has - * moved more than TRAIL_MIN_MOVE_M from the last trail vertex. - * @param {Object} record - Selected vessel record after an in-place update. - */ -function appendSelectedVesselTrailFix(record) { - if (!state.trail) return; - const next = vesselTrailPosition(record); - if (!next) return; - const last = state.trailPositions[state.trailPositions.length - 1]; - if (last && Cesium.Cartesian3.distance(last, next) <= TRAIL_MIN_MOVE_M) return; - state.trailPositions.push(next); - if (state.trailPositions.length > TRAIL_MAX_POINTS) state.trailPositions.shift(); - state.trail.setPositions(state.trailPositions); -} - -/** - * Clear the rendered trail and accumulation; invalidate pending backfills. - */ -function clearSelectedVesselTrail() { - state.trailBackfillToken += 1; - state.trailMmsi = null; - state.trailPositions = []; - if (state.trail) state.trail.clear(); -} - -/** - * Destroy the trail primitive entirely (layer disable/teardown). - */ -function destroySelectedVesselTrail() { - clearSelectedVesselTrail(); - if (state.trail) { - state.trail.destroy(); - state.trail = null; - } -} - -/** - * Register (or refresh) the selected vessel in the shared context store so - * the realtime/voice layer can describe what the user has selected. - * @param {Object} record - Selected vessel record. - * @returns {Object|null} The context record, or null if registration failed. - */ -function registerSelectedContext(record) { - if (!record?.mmsi) return null; - try { - return registerEntityContext(record, { - id: `ais-${record.mmsi}`, - layerId: 'ais-live-vessels', - layerName: 'Live AIS Vessels', - source: 'AISStream', - label: displayVesselName(record), - latitude: record.lat, - longitude: record.lon, - properties: { - mmsi: record.mmsi, - type: record.type, - speedKt: record.speed, - course: record.course, - destination: record.destination, - }, - }); - } catch (error) { - console.warn('[Data:ais-live-vessels] context register failed', error); - return null; - } -} - -function clearSelection({ preserveTrail = false, evicted = false } = {}) { - const record = state.selectedRecord; - if (record?.billboard) { - record.billboard.image = shipIcon(record, false); - record.billboard.scale = shipScale(record); - } - state.selectedRecord = null; - // Drop the full-detail card right away (no-op when the layer is disabled — - // disable() clears the entry set itself). - if (record && state.enabled) updateVisibility(true); - if (!preserveTrail) clearSelectedVesselTrail(); - try { - clearSelectedEntityContextForLayer('ais-live-vessels', { evicted }); - } catch (error) { - console.warn('[Data:ais-live-vessels] context clear failed', error); - } -} - -/** - * @param {object} [options] Clear origin. - * @param {boolean} [options.evicted=false] The vessel aged out of the feed - * rather than being deselected. - */ -function clearVesselInspection({ evicted = false } = {}) { - clearSelection({ evicted }); - resetSelectedVesselHud(); -} - -function updateSelectedVesselHud(record) { - const el = document.getElementById('hud-ais-vessel'); - if (!el) return; - - // Pinned vessels missing from recent refreshes get a stale marker - const stale = (record.missedRefreshes || 0) > 0; - el.classList.add('active'); - el.textContent = [ - `AIS: ${trimHudValue(record.name, 32)}`, - `${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`, - `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`, - ].join('\n'); -} - -function resetSelectedVesselHud() { - const el = document.getElementById('hud-ais-vessel'); - if (!el) return; - el.classList.remove('active'); - el.textContent = 'AIS: --'; -} - -function trimHudValue(value, maxLength) { - const text = String(value || '--').trim() || '--'; - return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; -} - -/** - * Card model for an ambient (decluttered-in) vessel — name title plus one - * compact type/speed/heading detail line, anchored at the record's current - * rendered position (height-datum caveat: no datum work here). Pure — - * exported for unit tests. - * @param {Object} record - Vessel record. - * @returns {Object} vesselLabels entry. - */ -export function buildVesselCard(record) { - const parts = []; - const type = vesselTypeShort(record); - if (type) parts.push(type); - if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); - const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); - return { - id: vesselOverlayEntryId(record), - actionable: Boolean(record?.mmsi), - position: record.billboard?.position || record.position, - gapPx: 10, - accent: accentForVesselType(record.type), - title: trimHudValue(displayVesselName(record), 26), - details: parts.length ? [parts.join(' · ')] : [], - selected: false, - priority: labelPriority(record, null), - }; -} - -/** - * Card model for the click-selected vessel — the full-detail card, drawn last - * (on top) and never distance-faded by the overlay. Pinned-but-vanished - * vessels carry a STALE marker (mirrors the HUD readout). Pure — exported - * for unit tests. - * @param {Object} record - Selected vessel record. - * @returns {Object} vesselLabels entry. - */ -export function buildSelectedVesselCard(record) { - const direction = record.heading ?? record.course; - const details = [[ - vesselTypeShort(record) || 'VESSEL', - formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', - ].join(' · ')]; - const destination = String(record.destination || '').trim(); - if (destination) details.push(`→ ${trimHudValue(destination, 24)}`); - const stale = (record.missedRefreshes || 0) > 0; - details.push(`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`); - return { - id: vesselOverlayEntryId(record), - actionable: Boolean(record?.mmsi), - position: record.billboard?.position || record.position, - gapPx: 12, - accent: accentForVesselType(record.type), - title: trimHudValue(displayVesselName(record), 32), - details, - selected: true, - priority: 100000, - }; -} - -/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */ -function vesselOverlayEntryId(record) { - const mmsi = String(record?.mmsi || '').trim(); - if (mmsi) return `vessel:${mmsi}`; - const name = String(record?.name || 'VESSEL').trim() || 'VESSEL'; - const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x'; - const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x'; - return `vessel:unkeyed:${name}:${lat}:${lon}`; -} - -/** Uppercased, card-width-bounded AIS type (empty string when unknown). */ -function vesselTypeShort(record) { - return normalizeVesselType(record.type).toUpperCase().slice(0, 14); -} - -/** - * True when `screen` is at least `minSepPx` away from every accepted screen - * position (greedy card-declutter accept test, mirroring the FIRMS pass). - * Exported for unit tests. - * @param {Array<{x: number, y: number}>} accepted - Accepted card positions. - * @param {{x: number, y: number}} screen - Candidate window coordinates. - * @param {number} minSepPx - Minimum separation in pixels. - * @returns {boolean} - */ -export function cardScreenSeparated(accepted, screen, minSepPx) { - const minSq = minSepPx * minSepPx; - for (let i = 0; i < accepted.length; i += 1) { - const dx = screen.x - accepted[i].x; - const dy = screen.y - accepted[i].y; - if (dx * dx + dy * dy < minSq) return false; - } - return true; -} - -function displayVesselName(record) { - const name = String(record.name || '').trim(); - if (name && name !== 'VESSEL' && name !== record.mmsi) return name; - return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL'; -} - -function formatSpeed(speed) { - return speed === null ? '--KT' : `${speed.toFixed(1)}KT`; -} - -function formatHeading(heading) { - return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG'; -} - -function formatPositionTime(record) { - if (!record.lastPositionUtc) return 'POS: LIVE'; - const date = new Date(record.lastPositionUtc); - if (Number.isNaN(date.getTime())) return 'POS: LIVE'; - return `POS: ${date.toISOString().slice(11, 19)}Z`; -} - -function setVisible(show) { - if (state.billboardCollection) { - state.billboardCollection.show = show; - } - _vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); -} - -function resetState() { - clearFirstConnectTimer(); - state.viewer = null; - state.enabled = false; - state.loading = false; - state.loaded = false; - state.stale = false; - state.error = null; - state.loadingLabel = ''; - state.lastUpdate = null; - state.count = 0; - state.newestPositionAt = null; - state.transportStatus = null; - state.nextAttemptAt = null; - state.lastMessageAt = null; - state.rawRowCount = 0; - state.acceptedRowCount = 0; - state.sessionId = ++_aisSessionSequence; - state.firstConnectPhase = 'idle'; - state.firstConnectStartedAt = null; - state.firstConnectDeadline = null; - state.firstConnectTimer = null; - state.abort = null; - state.billboardCollection = null; - state.vesselRecords = []; - state.vesselMap = new Map(); - state.unkeyedRecords = []; - state.clickHandler = null; - state.keyTarget = null; - state.keydownHandler = null; - state.trackedEntityRemover = null; - state.interactionHandlerFactory = null; - state.interactionKeyTarget = null; - state.preRenderRemover = null; - state.lastVisibilityUpdate = 0; - state.lastFocusUpdate = 0; - state.activeFocusCount = 0; - state.activeLabelCount = 0; - state.selectedRecord = null; - state.trail = null; - state.trailPositions = []; - state.trailMmsi = null; - state.trailBackfillToken = 0; -} - -/** - * Bind the production interaction callbacks to mockable viewer/handler - * surfaces. Test-only seam; behavior is shared with installInteraction(). - * @param {Object} viewer - Viewer-like object with scene.pick(). - * @param {Object} handler - Handler-like object with setInputAction(). - * @param {Object} keyTarget - EventTarget-like object with add/removeEventListener(). - * @returns {void} - */ -export function _bindVesselInteractionForTest(viewer, handler, keyTarget) { - bindVesselInteraction(viewer, handler, keyTarget); -} - -/** - * Prime the minimum live state needed by interaction/lifecycle wire tests. - * @param {Object} [options={}] - Test state values. - * @returns {void} - */ -export function _setVesselStateForTest(options = {}) { - resetState(); - const records = Array.isArray(options.records) ? options.records : []; - state.viewer = options.viewer || null; - state.enabled = options.enabled !== false; - state.loaded = options.loaded === true; - state.loading = options.loading === true; - state.stale = options.stale === true; - state.error = options.error || null; - state.lastUpdate = options.lastUpdate ?? null; - state.vesselRecords = records; - state.count = records.length; - state.vesselMap = new Map( - records.filter((record) => record?.mmsi).map((record) => [record.mmsi, record]) - ); - state.selectedRecord = options.selectedRecord || null; - state.billboardCollection = options.billboardCollection || { remove() {} }; - state.trail = options.trail || null; - state.trailMmsi = options.trailMmsi || null; - state.trailPositions = Array.isArray(options.trailPositions) ? [...options.trailPositions] : []; - state.transportStatus = options.transportStatus || null; - state.lastMessageAt = options.lastMessageAt ?? null; - state.rawRowCount = Number.isFinite(options.rawRowCount) ? options.rawRowCount : 0; - state.acceptedRowCount = Number.isFinite(options.acceptedRowCount) ? options.acceptedRowCount : records.length; - state.firstConnectPhase = options.firstConnectPhase || 'idle'; - state.firstConnectStartedAt = options.firstConnectStartedAt ?? null; - state.firstConnectDeadline = options.firstConnectDeadline ?? null; - state.interactionHandlerFactory = options.interactionHandlerFactory || null; - state.interactionKeyTarget = options.interactionKeyTarget || null; -} - -/** Inject a host recorder for lifecycle/contract tests; null restores production. */ -export function _setVesselOverlayHostForTest(host = null) { - _vesselOverlayHost = host || DEFAULT_VESSEL_OVERLAY_HOST; -} - -/** Exercise the production selector/publisher through a test-owned state. */ -export function _updateVesselCardsForTest(records = []) { - updateClusteredLabels(records); -} - -/** - * Reconcile AIS rows through the production lifecycle. Test-only seam. - * @param {Object} viewer - Viewer-like object. - * @param {Array} rows - Raw AIS rows. - * @returns {void} - */ -export function _reconcileVesselsForTest(viewer, rows) { - reconcileVessels(viewer, rows); -} - -/** Apply one server snapshot through the production pre-reconcile health gate. */ -export function _applyAisFeedSnapshotForTest(viewer, payload) { - return applyAisFeedSnapshot(viewer, payload); -} - -/** Exercise the request-owned live loader with a test-controlled fetch. */ -export function _loadLivePositionsForTest(viewer) { - return loadLivePositions(viewer); -} - -/** Start the production first-connect grace state without installing UI. */ -export function _beginAisSessionForTest() { - beginAisSession(); -} - -/** Inject a deterministic clock/scheduler; null restores production runtime. */ -export function _setAisRuntimeForTest(runtime = null) { - clearFirstConnectTimer(); - _aisRuntime = runtime - ? { - now: runtime.now, - setTimeout: runtime.setTimeout, - clearTimeout: runtime.clearTimeout, - } - : DEFAULT_AIS_RUNTIME; -} - -/** Read feed-health fields without exposing mutable production state. */ -export function _getVesselFeedStateForTest() { - const stats = aisLiveVesselsLayer.getStats(); - return { - count: state.count, - loaded: state.loaded, - loading: stats.loading, - loadingLabel: stats.loadingLabel, - stale: state.stale, - error: state.error, - status: stats.status, - lastUpdate: state.lastUpdate, - transportStatus: state.transportStatus, - lastMessageAt: state.lastMessageAt, - rawRowCount: state.rawRowCount, - acceptedRowCount: state.acceptedRowCount, - selectedMmsi: state.selectedRecord?.mmsi || null, - trailMmsi: state.trailMmsi, - trailPositionCount: state.trailPositions.length, - sessionId: state.sessionId, - firstConnectPhase: state.firstConnectPhase, - firstConnectStartedAt: state.firstConnectStartedAt, - firstConnectDeadline: state.firstConnectDeadline, - }; -} - -/** - * Read lifecycle ownership state without exposing the mutable state object. - * Test-only seam. - * @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}} - */ -export function _getVesselStateForTest() { - return { - trailMmsi: state.trailMmsi, - trailPositionCount: state.trailPositions.length, - vesselCount: state.vesselMap.size, - }; -} diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index a9722b9c6..d714f486e 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -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()); + } +}); diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 087280453..a07140c74 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -1,1676 +1,24 @@ -/** - * @module bikeshare - * @description GBFS bikeshare station data overlay with real-time availability. - * - * Fetches station information and status from GBFS-compliant feeds for major - * US bikeshare systems, renders stations as color-coded point primitives on the - * Cesium globe, and provides click-to-inspect and HUD detection integration. - * - * Stations are loaded on-demand based on camera proximity and altitude gating, - * with periodic status polling to keep availability colors current. - */ +import { createBikeshareSource } from '../layers/bikeshare/source.js'; +import { createBikeshareLayer } from '../layers/bikeshare/index.js'; +import * as render from '../renderGovernor.js'; +import * as sprites from './spriteOrder.js'; +import * as picking from './pickRegistry.js'; +import * as overlays from '../overlays/worldOverlay.js'; -import * as Cesium from 'cesium'; -import { governorRequestRender } from '../renderGovernor.js'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; - -export const BIKESHARE_SELECTED_OVERLAY_SOURCE_ID = 'bikeshare-selected'; -export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 1, - collisionCapacity: 0, - moving: false, +const layer = createBikeshareLayer({ + source: createBikeshareSource(), + services: { render, sprites, picking, overlays }, }); - -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); -let _overlayHost = DEFAULT_OVERLAY_HOST; - -// --- Activation / display thresholds --- -/** Altitude (m) at which bikeshare layer becomes eligible for display. */ -const ACTIVATION_ALTITUDE_M = 50000; -/** Hysteresis enter threshold — layer activates when camera drops below this. */ -const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 2000; -/** Hysteresis exit threshold — layer deactivates when camera rises above this. */ -const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 2000; -/** Debounce interval (ms) for camera-change proximity checks. */ -const CAMERA_DEBOUNCE_MS = 340; -/** Default radius (km) for determining whether a city is in camera range. */ -const CITY_RANGE_BASE_KM = 100; -/** Polling interval (ms) for refreshing station status data. */ -const STATUS_POLL_MS = 60000; - -// --- Point rendering constants --- -/** Minimum rendered point size in pixels. */ -const POINT_SIZE_MIN = 4; -/** Maximum rendered point size in pixels. */ -const POINT_SIZE_MAX = 12; -/** Fallback station capacity when real data is unavailable. */ -const DEFAULT_CAPACITY = 15; -/** Vertical offset (m) above terrain for station points. */ -const POINT_HEIGHT_OFFSET_M = 2.0; -/** Hard cap on total rendered station points across all cities. */ -const MAX_TOTAL_POINTS = 8000; - -// --- Availability color palette --- -/** Station has >60% bikes available. */ -const COLOR_GREEN = Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.95); -/** Station has 30-60% bikes available. */ -const COLOR_YELLOW = Cesium.Color.fromCssColorString('#ffaa00').withAlpha(0.94); -/** Station has <30% bikes available. */ -const COLOR_RED = Cesium.Color.fromCssColorString('#ff4444').withAlpha(0.94); -/** No status data available for station. */ -const COLOR_NEUTRAL = Cesium.Color.fromCssColorString('#91a4b4').withAlpha(0.62); -/** Station is offline (not installed, not renting, or not returning). */ -const COLOR_MUTED = Cesium.Color.fromCssColorString('#687581').withAlpha(0.48); -/** Outline color for all station points. */ -const COLOR_OUTLINE = Cesium.Color.BLACK.withAlpha(0.25); - -/** - * Build GBFS endpoint URLs for a BCycle-hosted system. - * @param {string} systemId - BCycle system identifier (e.g. 'bcycle_boulder'). - * @returns {{ stationInformationUrl: string, stationStatusUrl: string }} - */ -function buildBcycleUrls(systemId) { - return { - stationInformationUrl: `https://gbfs.bcycle.com/${systemId}/station_information.json`, - stationStatusUrl: `https://gbfs.bcycle.com/${systemId}/station_status.json`, - }; -} - -/** - * Convenience factory for a BCycle-hosted city registry entry. - * Merges caller-supplied metadata with auto-generated BCycle GBFS URLs. - * @param {Object} opts - * @param {string} opts.id - Unique city identifier. - * @param {string} opts.city - Human-readable city name. - * @param {number} opts.centerLat - City center latitude. - * @param {number} opts.centerLon - City center longitude. - * @param {string} opts.systemId - BCycle system identifier. - * @param {number} [opts.loadRadiusKm=100] - Activation radius in km. - * @param {string} [opts.provider='BCycle'] - Display provider name. - * @returns {Object} Raw registry entry suitable for RAW_GBFS_CITY_REGISTRY. - */ -function bcycleEntry({ id, city, centerLat, centerLon, systemId, loadRadiusKm = 100, provider = 'BCycle' }) { - return { - id, - city, - centerLat, - centerLon, - loadRadiusKm, - provider, - ...buildBcycleUrls(systemId), - }; -} - -/** - * Raw registry of supported GBFS bikeshare cities. - * Each entry specifies the city center, load radius, GBFS feed URLs, and provider name. - * Entries using BCycle hosting are constructed via bcycleEntry() for brevity. - * @type {Object[]} - */ -const RAW_GBFS_CITY_REGISTRY = [ - { - id: 'nyc-citibike', - city: 'New York, NY', - centerLat: 40.7484, - centerLon: -73.9967, - loadRadiusKm: 140, - stationInformationUrl: 'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_information.json', - stationStatusUrl: 'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_status.json', - provider: 'Citi Bike', - }, - { - id: 'chicago-divvy', - city: 'Chicago, IL', - centerLat: 41.8781, - centerLon: -87.6298, - loadRadiusKm: 120, - stationInformationUrl: 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_information.json', - stationStatusUrl: 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json', - provider: 'Divvy', - }, - { - id: 'dc-capital-bikeshare', - city: 'Washington, DC', - centerLat: 38.9072, - centerLon: -77.0369, - loadRadiusKm: 120, - stationInformationUrl: 'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_information.json', - stationStatusUrl: 'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_status.json', - provider: 'Capital Bikeshare', - }, - { - id: 'sf-bay-wheels', - city: 'San Francisco, CA', - centerLat: 37.7749, - centerLon: -122.4194, - loadRadiusKm: 110, - stationInformationUrl: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_information.json', - stationStatusUrl: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json', - provider: 'Bay Wheels', - }, - { - id: 'boston-bluebikes', - city: 'Boston, MA', - centerLat: 42.3601, - centerLon: -71.0589, - loadRadiusKm: 100, - stationInformationUrl: 'https://gbfs.bluebikes.com/gbfs/en/station_information.json', - stationStatusUrl: 'https://gbfs.bluebikes.com/gbfs/en/station_status.json', - provider: 'Blue Bikes', - }, - { - id: 'philadelphia-indego', - city: 'Philadelphia, PA', - centerLat: 39.9526, - centerLon: -75.1652, - loadRadiusKm: 100, - stationInformationUrl: 'https://gbfs.bcycle.com/bcycle_indego/station_information.json', - stationStatusUrl: 'https://gbfs.bcycle.com/bcycle_indego/station_status.json', - provider: 'Indego', - }, - { - id: 'portland-biketown', - city: 'Portland, OR', - centerLat: 45.5152, - centerLon: -122.6784, - loadRadiusKm: 95, - stationInformationUrl: 'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_information.json', - stationStatusUrl: 'https://gbfs.biketownpdx.com/gbfs/2.3/en/station_status.json', - provider: 'BIKETOWN', - }, - { - id: 'la-metro-bike', - city: 'Los Angeles, CA', - centerLat: 34.0522, - centerLon: -118.2437, - loadRadiusKm: 120, - stationInformationUrl: 'https://gbfs.bcycle.com/bcycle_lametro/station_information.json', - stationStatusUrl: 'https://gbfs.bcycle.com/bcycle_lametro/station_status.json', - provider: 'Metro Bike', - }, - { - id: 'austin-capmetro', - city: 'Austin, TX', - centerLat: 30.2672, - centerLon: -97.7431, - loadRadiusKm: 90, - stationInformationUrl: 'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', - stationStatusUrl: 'https://austin.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', - provider: 'CapMetro', - }, - { - id: 'honolulu-biki', - city: 'Honolulu, HI', - centerLat: 21.3069, - centerLon: -157.8583, - loadRadiusKm: 90, - stationInformationUrl: 'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', - stationStatusUrl: 'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', - provider: 'Biki', - }, - { - id: 'columbus-cogo', - city: 'Columbus, OH', - centerLat: 39.9612, - centerLon: -82.9988, - loadRadiusKm: 90, - stationInformationUrl: 'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json', - stationStatusUrl: 'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json', - provider: 'CoGo', - }, - { - id: 'chattanooga-bikechatt', - city: 'Chattanooga, TN', - centerLat: 35.0456, - centerLon: -85.3097, - loadRadiusKm: 90, - stationInformationUrl: 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', - stationStatusUrl: 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', - provider: 'Bike Chattanooga', - }, - bcycleEntry({ - id: 'boulder-bcycle', - city: 'Boulder, CO', - centerLat: 40.0150, - centerLon: -105.2705, - systemId: 'bcycle_boulder', - }), - bcycleEntry({ - id: 'milwaukee-bublr', - city: 'Milwaukee, WI', - centerLat: 43.0389, - centerLon: -87.9065, - systemId: 'bcycle_bublr', - provider: 'Bublr', - }), - bcycleEntry({ - id: 'madison-bcycle', - city: 'Madison, WI', - centerLat: 43.0731, - centerLon: -89.4012, - systemId: 'bcycle_madison', - }), - bcycleEntry({ - id: 'nashville-bcycle', - city: 'Nashville, TN', - centerLat: 36.1627, - centerLon: -86.7816, - systemId: 'bcycle_nashville', - }), - bcycleEntry({ - id: 'salt-lake-greenbike', - city: 'Salt Lake City, UT', - centerLat: 40.7608, - centerLon: -111.8910, - systemId: 'bcycle_greenbikeslc', - provider: 'GREENbike', - }), - bcycleEntry({ - id: 'san-antonio-bcycle', - city: 'San Antonio, TX', - centerLat: 29.4241, - centerLon: -98.4936, - systemId: 'bcycle_sanantonio', - }), - bcycleEntry({ - id: 'cincinnati-red-bike', - city: 'Cincinnati, OH', - centerLat: 39.1031, - centerLon: -84.5120, - systemId: 'bcycle_cincyredbike', - provider: 'Red Bike', - }), - bcycleEntry({ - id: 'el-paso-bcycle', - city: 'El Paso, TX', - centerLat: 31.7619, - centerLon: -106.4850, - systemId: 'bcycle_elpaso', - }), - bcycleEntry({ - id: 'indianapolis-pacers', - city: 'Indianapolis, IN', - centerLat: 39.7684, - centerLon: -86.1581, - systemId: 'bcycle_pacersbikeshare', - provider: 'Pacers Bikeshare', - }), - bcycleEntry({ - id: 'fort-lauderdale-broward', - city: 'Fort Lauderdale, FL', - centerLat: 26.1224, - centerLon: -80.1373, - systemId: 'bcycle_broward', - provider: 'Broward B-cycle', - }), - bcycleEntry({ - id: 'memphis-bcycle', - city: 'Memphis, TN', - centerLat: 35.1495, - centerLon: -90.0490, - systemId: 'bcycle_memphis', - }), - bcycleEntry({ - id: 'des-moines-bcycle', - city: 'Des Moines, IA', - centerLat: 41.5868, - centerLon: -93.6250, - systemId: 'bcycle_desmoines', - }), - bcycleEntry({ - id: 'tucson-tugo', - city: 'Tucson, AZ', - centerLat: 32.2226, - centerLon: -110.9747, - systemId: 'bcycle_tugo', - provider: 'Tugo', - }), - bcycleEntry({ - id: 'fort-worth-trinity', - city: 'Fort Worth, TX', - centerLat: 32.7555, - centerLon: -97.3308, - systemId: 'bcycle_fortworth', - provider: 'Trinity Metro', - }), - bcycleEntry({ - id: 'omaha-heartland', - city: 'Omaha, NE', - centerLat: 41.2565, - centerLon: -95.9345, - systemId: 'bcycle_heartland', - provider: 'Heartland B-cycle', - }), - bcycleEntry({ - id: 'lincoln-bikelnk', - city: 'Lincoln, NE', - centerLat: 40.8136, - centerLon: -96.7026, - systemId: 'bcycle_bikelnk', - provider: 'BikeLNK', - }), - bcycleEntry({ - id: 'greenville-sc-bcycle', - city: 'Greenville, SC', - centerLat: 34.8526, - centerLon: -82.3940, - systemId: 'bcycle_greenville', - }), - bcycleEntry({ - id: 'buffalo-reddy', - city: 'Buffalo, NY', - centerLat: 42.8864, - centerLon: -78.8784, - systemId: 'bcycle_reddy', - provider: 'Reddy Bikeshare', - }), - bcycleEntry({ - id: 'las-vegas-rtc-bike-share', - city: 'Las Vegas, NV', - centerLat: 36.1699, - centerLon: -115.1398, - systemId: 'bcycle_rtcbikeshare', - provider: 'RTC Bike Share', - }), - bcycleEntry({ - id: 'santa-barbara-bcycle', - city: 'Santa Barbara, CA', - centerLat: 34.4208, - centerLon: -119.6982, - systemId: 'bcycle_santabarbara', - }), -]; - -/** - * Validate and normalize a raw GBFS city registry entry. - * Ensures required fields are present, URLs are HTTPS and match expected - * GBFS endpoint patterns, and coordinates are finite numbers. - * @param {Object} entry - Raw registry entry from RAW_GBFS_CITY_REGISTRY. - * @returns {Object} Normalized entry with validated fields and extracted hostnames. - * @throws {Error} If any required field is missing or invalid. - */ -function normalizeRegistryEntry(entry) { - const id = String(entry?.id || '').trim().toLowerCase(); - const city = String(entry?.city || '').trim(); - const centerLat = Number(entry?.centerLat); - const centerLon = Number(entry?.centerLon); - const loadRadiusKm = Number(entry?.loadRadiusKm); - const stationInformationUrl = new URL(String(entry?.stationInformationUrl || '').trim()); - const stationStatusUrl = new URL(String(entry?.stationStatusUrl || '').trim()); - - if (!id) throw new Error('GBFS entry id is required'); - if (!city) throw new Error(`GBFS entry "${id}" city is required`); - if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) { - throw new Error(`GBFS entry "${id}" has invalid center coordinates`); - } - // Enforce HTTPS-only for GBFS feeds - if (stationInformationUrl.protocol !== 'https:' || stationStatusUrl.protocol !== 'https:') { - throw new Error(`GBFS entry "${id}" must use https URLs`); - } - // Validate that URLs end with expected GBFS endpoint filenames - if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) { - throw new Error(`GBFS entry "${id}" station_information URL is invalid`); - } - if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) { - throw new Error(`GBFS entry "${id}" station_status URL is invalid`); - } - - // Deduplicate hostnames across both feed URLs for proxy allowlisting - const hosts = Array.from( - new Set([stationInformationUrl.hostname.toLowerCase(), stationStatusUrl.hostname.toLowerCase()]) - ); - - return { - id, - city, - centerLat, - centerLon, - loadRadiusKm: Number.isFinite(loadRadiusKm) && loadRadiusKm > 0 ? loadRadiusKm : CITY_RANGE_BASE_KM, - stationInformationUrl: stationInformationUrl.toString(), - stationStatusUrl: stationStatusUrl.toString(), - provider: String(entry?.provider || 'GBFS').trim() || 'GBFS', - hosts, - }; -} - -/** - * Validated and deduplicated city registry, built at module load time. - * Throws if any entry fails validation or has a duplicate id. - * @type {Object[]} - */ -const GBFS_CITY_REGISTRY = (() => { - const seen = new Set(); - return RAW_GBFS_CITY_REGISTRY.map((entry) => { - const normalized = normalizeRegistryEntry(entry); - if (seen.has(normalized.id)) { - throw new Error(`Duplicate GBFS city id: ${normalized.id}`); - } - seen.add(normalized.id); - return normalized; - }); -})(); - -/** Lookup map from city id to its normalized registry entry. */ -const CITY_BY_ID = new Map(GBFS_CITY_REGISTRY.map((entry) => [entry.id, entry])); - -// --------------------------------------------------------------------------- -// Module-level mutable state -// --------------------------------------------------------------------------- - -/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */ -let _viewer = null; -/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */ -let _pointCollection = null; -/** Whether the bikeshare layer is currently enabled. */ -let _enabled = false; -/** Timer handle for camera-move debounce. */ -let _cameraDebounceTimer = null; -/** Whether the camera.changed listener is currently attached. */ -let _cameraChangedAttached = false; -/** Hysteresis flag for altitude-based activation. */ -let _altitudeGateEnabled = false; -/** Monotonic generation counter; incremented on each proximity check to cancel stale work. */ -let _proximityGeneration = 0; - -/** @type {Set} City ids currently considered in-range. */ -let _activeCityIds = new Set(); -/** @type {Map }>} Per-city runtime tracking of rendered station keys. */ -let _cityRuntime = new Map(); - -/** @type {Map>} Cached station information per city (cityId -> stationId -> StationInfo). */ -let _stationInfoCache = new Map(); -/** @type {Map }>} Cached station status per city. */ -let _statusCache = new Map(); -/** @type {Map} In-flight station info requests. */ -let _inFlightInfo = new Map(); -/** @type {Map} In-flight station status requests. */ -let _inFlightStatus = new Map(); - -/** @type {Map} Render records keyed by "cityId:stationId". */ -let _stationRenderMap = new Map(); -/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */ -let _clickHandler = null; -/** @type {string|null} Key of the currently selected station, or null. */ -let _selectedKey = null; -/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */ -let _selectedEntity = null; - -/** Total number of currently rendered station points. */ -let _count = 0; -/** Timestamp (ms) of the last successful status update. */ -let _lastUpdate = null; -/** Whether any GBFS fetch is currently in progress. */ -let _loading = false; -/** Reference count of concurrent loading operations. */ -let _loadingOps = 0; -/** Most recent error message string, or null. */ -let _error = null; -/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */ -let _limitWarned = false; - -/** - * Convert an upstream GBFS URL into a local proxy URL. - * The dev server proxies /api/gbfs/* to avoid CORS issues with third-party feeds. - * @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL. - * @returns {string} Relative proxy URL. - */ -function toProxyUrl(upstreamUrl) { - return `/api/gbfs/${encodeURIComponent(upstreamUrl)}`; -} - -/** Increment the loading reference count and mark loading state active. */ -function beginLoading() { - _loadingOps++; - _loading = true; -} - -/** Decrement the loading reference count; clear loading flag when zero. */ -function endLoading() { - _loadingOps = Math.max(0, _loadingOps - 1); - _loading = _loadingOps > 0; -} - -/** - * Coerce a GBFS boolean field to a native boolean. - * GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings. - * @param {*} value - Raw field value from GBFS JSON. - * @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized. - * @returns {boolean} - */ -function normalizeGbfsBool(value, fallback = true) { - if (value == null) return fallback; - if (typeof value === 'boolean') return value; - const num = Number(value); - if (Number.isFinite(num)) return num !== 0; - const text = String(value).trim().toLowerCase(); - if (text === 'true' || text === 'yes') return true; - if (text === 'false' || text === 'no') return false; - return fallback; -} - -/** - * Parse a value as a non-negative integer, returning fallback on failure. - * @param {*} value - Raw numeric value. - * @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number. - * @returns {number|null} - */ -function toNonNegativeInteger(value, fallback = null) { - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return fallback; - return Math.round(n); -} - -/** - * Build a globally unique render key for a station. - * @param {string} cityId - City identifier. - * @param {string} stationId - Station identifier within the city. - * @returns {string} Composite key in "cityId:stationId" format. - */ -function stationKey(cityId, stationId) { - return `${cityId}:${stationId}`; -} - -/** - * Get the camera's current altitude in meters above the ellipsoid. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - * @returns {number} Altitude in meters, or Infinity if unavailable. - */ -function getCameraAltitude(viewer) { - const carto = viewer?.camera?.positionCartographic; - return carto && Number.isFinite(carto.height) ? carto.height : Infinity; -} - -/** - * Determine the lat/lon the camera is currently looking at. - * Prefers the center of the computed view rectangle; falls back to the - * camera's own cartographic position when the rectangle is unavailable. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - * @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null. - */ -function getCameraCenterLatLon(viewer) { - // Try view rectangle center first (more accurate for tilted views) - const rect = viewer?.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid); - if (rect) { - const center = Cesium.Rectangle.center(rect); - return { - lat: Cesium.Math.toDegrees(center.latitude), - lon: Cesium.Math.toDegrees(center.longitude), - }; - } - - // Fallback: use the camera's own position - const carto = viewer?.camera?.positionCartographic; - if (carto) { - return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), - }; - } - - return null; -} - -/** - * Compute the great-circle distance between two points using the Haversine formula. - * @param {number} aLat - Latitude of point A in degrees. - * @param {number} aLon - Longitude of point A in degrees. - * @param {number} bLat - Latitude of point B in degrees. - * @param {number} bLon - Longitude of point B in degrees. - * @returns {number} Distance in kilometers. - */ -function haversineKm(aLat, aLon, bLat, bLon) { - const toRad = (value) => value * Math.PI / 180; - const dLat = toRad(bLat - aLat); - const dLon = toRad(bLon - aLon); - const p1 = toRad(aLat); - const p2 = toRad(bLat); - const a = Math.sin(dLat / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -/** - * Determine whether the layer should be active at the given camera altitude. - * Uses hysteresis (separate enter/exit thresholds) to avoid rapid toggling - * when the camera hovers near the activation boundary. - * @param {number} altitude - Camera altitude in meters. - * @returns {boolean} True if the layer should remain/become active. - */ -function shouldActivateAtAltitude(altitude) { - if (!Number.isFinite(altitude)) { - _altitudeGateEnabled = false; - return false; - } - if (_altitudeGateEnabled) { - // Deactivate only after crossing the higher exit threshold - if (altitude >= ACTIVATION_EXIT_ALTITUDE_M) _altitudeGateEnabled = false; - } else if (altitude <= ACTIVATION_ENTER_ALTITUDE_M) { - // Activate when dropping below the lower enter threshold - _altitudeGateEnabled = true; - } - return _altitudeGateEnabled; -} - -/** - * Determine which cities are close enough to the camera to warrant loading. - * Compares haversine distance from the camera center to each city's center - * against the city's configured load radius. - * @param {{ lat: number, lon: number }} center - Camera center in degrees. - * @returns {Set} Set of city ids currently within load range. - */ -function computeInRangeCities(center) { - const active = new Set(); - if (!center) return active; - - for (const city of GBFS_CITY_REGISTRY) { - const distance = haversineKm(center.lat, center.lon, city.centerLat, city.centerLon); - const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm); - if (distance <= radius) active.add(city.id); - } - - return active; -} - -/** - * Extract the stations array from a GBFS JSON payload. - * Handles multiple response shapes: { data: { stations: [...] } }, - * { data: [...] }, and nested objects with a stations sub-key. - * @param {Object} payload - Parsed GBFS JSON response. - * @returns {Object[]} Array of raw station objects (may be empty). - */ -function extractStationsArray(payload) { - const data = payload?.data; - if (Array.isArray(data?.stations)) return data.stations; - if (Array.isArray(data)) return data; - // Some feeds nest stations under an additional key (e.g. locale wrappers) - if (data && typeof data === 'object') { - for (const value of Object.values(data)) { - if (Array.isArray(value?.stations)) return value.stations; - } - } - return []; -} - -/** - * Parse a GBFS station_information payload into a Map of station metadata. - * Handles field-name variations across different GBFS providers - * (station_id vs id, lat vs latitude, etc.). Stations with missing or - * invalid coordinates are silently skipped. - * @param {Object} payload - Parsed station_information.json response. - * @returns {Map} Map of stationId to station info objects. - */ -function parseStationInformation(payload) { - const stations = extractStationsArray(payload); - const stationMap = new Map(); - - for (const raw of stations) { - // Accept both GBFS 2.x (station_id) and alternate (id) field names - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; - - stationMap.set(stationId, { - stationId, - name: String(raw?.name || raw?.short_name || '').trim(), - lat, - lon, - capacity: toNonNegativeInteger(raw?.capacity), - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - }); - } - - return stationMap; -} - -/** - * Parse a GBFS station_status payload into a Map of real-time availability. - * @param {Object} payload - Parsed station_status.json response. - * @returns {Map} Map of stationId to status objects containing - * bikesAvailable, docksAvailable, and operational flags. - */ -function parseStationStatus(payload) { - const stations = extractStationsArray(payload); - const statusMap = new Map(); - - for (const raw of stations) { - const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - if (!stationId) continue; - - const bikes = toNonNegativeInteger(raw?.num_bikes_available); - const docks = toNonNegativeInteger(raw?.num_docks_available); - statusMap.set(stationId, { - stationId, - bikesAvailable: bikes, - docksAvailable: docks, - isInstalled: normalizeGbfsBool(raw?.is_installed, true), - isRenting: normalizeGbfsBool(raw?.is_renting, true), - isReturning: normalizeGbfsBool(raw?.is_returning, true), - lastReported: toNonNegativeInteger(raw?.last_reported), - }); - } - - return statusMap; -} - -/** - * Fetch and parse JSON from a GBFS endpoint via the local proxy. - * @param {string} upstreamUrl - Full HTTPS GBFS endpoint URL. - * @param {Object} [options] - * @param {AbortSignal} [options.signal] - Optional abort signal for cancellation. - * @returns {Promise} Parsed JSON payload. - * @throws {Error} On non-OK HTTP status or malformed JSON. - */ -async function fetchGbfsJson(upstreamUrl, { signal } = {}) { - const response = await fetch(toProxyUrl(upstreamUrl), { - method: 'GET', - headers: { Accept: 'application/json' }, - signal, - }); - - if (!response.ok) { - throw new Error(`GBFS HTTP ${response.status}`); - } - - const payload = await response.json(); - if (!payload || typeof payload !== 'object') { - throw new Error('Malformed GBFS payload'); - } - return payload; -} - -/** - * Abort an in-flight GBFS request for a specific city and remove it from the map. - * @param {Map} map - In-flight request map. - * @param {string} cityId - City whose request should be cancelled. - */ -function abortInFlight(map, cityId) { - const entry = map.get(cityId); - if (!entry) return; - try { - entry.controller?.abort(); - } catch { - // no-op - } - map.delete(cityId); -} - -/** Abort all in-flight station info and status requests across all cities. */ -function abortAllInFlight() { - for (const cityId of _inFlightInfo.keys()) abortInFlight(_inFlightInfo, cityId); - for (const cityId of _inFlightStatus.keys()) abortInFlight(_inFlightStatus, cityId); -} - -/** - * Load station information for a city. Returns cached data if available, - * deduplicates concurrent requests, and caches the result on success. - * Station info is static metadata (location, name, capacity) and is - * fetched once per city per session. - * @param {string} cityId - City identifier. - * @param {number} generation - Proximity generation to detect stale requests. - * @returns {Promise>} Map of stationId to station info. - * @throws {Error} On unknown city, fetch failure, or empty station list. - */ -async function loadCityStationInfo(cityId, generation) { - if (_stationInfoCache.has(cityId)) return _stationInfoCache.get(cityId); - const inFlight = _inFlightInfo.get(cityId); - if (inFlight) return inFlight.promise; - - const city = CITY_BY_ID.get(cityId); - if (!city) throw new Error(`Unknown GBFS city "${cityId}"`); - const controller = new AbortController(); - - const promise = (async () => { - beginLoading(); - try { - const payload = await fetchGbfsJson(city.stationInformationUrl, { signal: controller.signal }); - const stationMap = parseStationInformation(payload); - if (stationMap.size === 0) { - throw new Error(`No station information for ${city.city}`); - } - _stationInfoCache.set(cityId, stationMap); - return stationMap; - } finally { - endLoading(); - } - })().finally(() => { - // Clean up in-flight entry only if it is still ours (not replaced by a newer request) - const current = _inFlightInfo.get(cityId); - if (current && current.controller === controller) _inFlightInfo.delete(cityId); - }); - - _inFlightInfo.set(cityId, { promise, controller, generation }); - return promise; -} - -/** - * Load real-time station status for a city (bike/dock counts, operational flags). - * Unlike station info, status is NOT served from cache — it is always re-fetched - * to keep availability data current. Concurrent requests are deduplicated. - * @param {string} cityId - City identifier. - * @param {number} generation - Proximity generation to detect stale requests. - * @returns {Promise>} Map of stationId to status objects. - * @throws {Error} On unknown city or fetch failure. - */ -async function loadCityStationStatus(cityId, generation) { - const inFlight = _inFlightStatus.get(cityId); - if (inFlight) return inFlight.promise; - - const city = CITY_BY_ID.get(cityId); - if (!city) throw new Error(`Unknown GBFS city "${cityId}"`); - const controller = new AbortController(); - - const promise = (async () => { - beginLoading(); - try { - const payload = await fetchGbfsJson(city.stationStatusUrl, { signal: controller.signal }); - const statusMap = parseStationStatus(payload); - _statusCache.set(cityId, { - statusMap, - timestamp: Date.now(), - }); - return statusMap; - } finally { - endLoading(); - } - })().finally(() => { - const current = _inFlightStatus.get(cityId); - if (current && current.controller === controller) _inFlightStatus.delete(cityId); - }); - - _inFlightStatus.set(cityId, { promise, controller, generation }); - return promise; -} - -/** - * Resolve the effective dock capacity for a station. - * Prefers the explicit capacity from station_information; falls back to - * bikes+docks from status data; uses DEFAULT_CAPACITY as last resort. - * @param {number|null} capacityFromInfo - Capacity from station_information, or null. - * @param {Object|null} status - Station status object with bikesAvailable/docksAvailable. - * @returns {number} Resolved capacity (always > 0). - */ -function resolveCapacity(capacityFromInfo, status) { - const fromInfo = toNonNegativeInteger(capacityFromInfo); - if (fromInfo && fromInfo > 0) return fromInfo; - - // Derive capacity from current bikes + available docks - const bikes = toNonNegativeInteger(status?.bikesAvailable, 0); - const docks = toNonNegativeInteger(status?.docksAvailable, 0); - const derived = bikes + docks; - if (derived > 0) return derived; - - return DEFAULT_CAPACITY; -} - -/** - * Map station capacity to a point pixel size using a sqrt scale. - * Larger-capacity stations render as bigger dots; clamped to [POINT_SIZE_MIN, POINT_SIZE_MAX]. - * @param {number} capacity - Station dock capacity. - * @returns {number} Point size in pixels. - */ -function capacityToPixelSize(capacity) { - const c = Math.max(1, Math.min(80, Number(capacity) || DEFAULT_CAPACITY)); - // sqrt scale so large stations don't dominate visually - const normalized = Math.sqrt(c) / Math.sqrt(80); - const size = POINT_SIZE_MIN + normalized * (POINT_SIZE_MAX - POINT_SIZE_MIN); - return Math.max(POINT_SIZE_MIN, Math.min(POINT_SIZE_MAX, size)); -} - -/** - * Determine the display color for a station based on its availability ratio. - * - No status data: neutral gray. - * - Offline (not installed/renting/returning): muted gray. - * - >60% bikes available: green. - * - 30-60% bikes available: yellow. - * - <30% bikes available: red. - * @param {Object|null} status - Station status object. - * @param {number} capacity - Resolved station capacity. - * @returns {Cesium.Color} Color to apply to the station point. - */ -function statusToColor(status, capacity) { - if (!status) return COLOR_NEUTRAL; - if (!status.isInstalled || !status.isRenting || !status.isReturning) return COLOR_MUTED; - - const bikes = toNonNegativeInteger(status.bikesAvailable); - if (!Number.isFinite(bikes)) return COLOR_NEUTRAL; - - const cap = Math.max(1, Number(capacity) || DEFAULT_CAPACITY); - const ratio = bikes / cap; - if (ratio > 0.6) return COLOR_GREEN; - if (ratio >= 0.3) return COLOR_YELLOW; - return COLOR_RED; -} - -/** - * Build a multi-line text label for the selected station popup. - * Shows station name, availability counts, and any abnormal operational flags. - * @param {Object} record - Render record from _stationRenderMap. - * @returns {string} Newline-delimited source text for the selected host card. - */ -function buildSelectionLabel(record) { - const stationName = String(record?.stationName || '').trim(); - const stationLabel = stationName || (record?.stationId ? `Station ${record.stationId}` : 'Station'); - const bikes = Number.isFinite(record?.bikesAvailable) ? record.bikesAvailable : '?'; - const docks = Number.isFinite(record?.docksAvailable) ? record.docksAvailable : '?'; - const capacity = Number.isFinite(record?.capacity) ? record.capacity : '?'; - - const lines = [ - stationLabel, - `🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`, - ]; - - // Append warnings for stations that are offline or partially non-operational - const abnormal = []; - if (record?.isInstalled === false) abnormal.push('⚠️ Not installed'); - if (record?.isRenting === false) abnormal.push('⚠️ Not renting'); - if (record?.isReturning === false) abnormal.push('⚠️ Not returning'); - if (abnormal.length > 0) { - lines.push(abnormal.join(' · ')); - } - - return lines.join('\n'); -} - -/** - * Build the protected selected-station entry from source-owned copy. - * @param {string} key Stable city/station composite key. - * @param {Object} record Bikeshare render record. - * @returns {Object|null} - */ -export function createBikeshareSelectedOverlayEntry(key, record) { - const position = record?.point?.position; - if (!key || !position) return null; - const [title, ...details] = buildSelectionLabel(record).split('\n'); - return { - id: String(key), - position, - variant: 'selected', - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-card', - priority: Number.MAX_SAFE_INTEGER, - title, - details, - accent: '#00ffff', - interactive: false, - anchorRadiusPx: 9, - minAnchorGapPx: 11, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** - * Clear the current station selection. - * Re-shows the hidden point primitive and removes the highlight entity. - */ -function _clearSelection() { - if (_selectedKey) { - const record = _stationRenderMap.get(_selectedKey); - if (record?.point) { - record.point.show = true; - } - } - - if (_selectedEntity && _viewer) { - _viewer.entities.remove(_selectedEntity); - } - - _selectedKey = null; - _selectedEntity = null; - _overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID); -} - -/** - * Select a station by key: hides the original point primitive and adds a - * highlighted cyan entity plus a protected shared-host availability card. - * @param {string} key - Composite "cityId:stationId" key. - */ -function _selectStation(key) { - _clearSelection(); - - const record = _stationRenderMap.get(key); - if (!record || !record.point?.position || !_viewer) return; - - _selectedKey = key; - // Hide the base point so the highlight entity replaces it visually - record.point.show = false; - - _selectedEntity = _viewer.entities.add({ - position: record.point.position, - point: { - pixelSize: 14, - color: Cesium.Color.CYAN, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - const entry = createBikeshareSelectedOverlayEntry(key, record); - if (entry) { - _overlayHost.setEntries( - BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, - [entry], - BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, - ); - } -} - -/** - * Install a screen-space click handler for station selection/deselection. - * Also registers a global keydown listener for Escape-to-deselect. - * Idempotent — does nothing if a handler is already installed. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - */ -function _installClickHandler(viewer) { - if (_clickHandler) return; - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking selected entity itself — ignore (don't deselect). - if (picked.id === _selectedEntity) return; - - // Check if the picked primitive or entity id matches a station key - const primitive = picked.primitive; - if (primitive && typeof primitive.id === 'string' && _stationRenderMap.has(primitive.id)) { - _selectStation(primitive.id); - return; - } - if (typeof picked.id === 'string' && _stationRenderMap.has(picked.id)) { - _selectStation(picked.id); - return; - } - } - - // Clicked empty space — deselect. - if (_selectedKey) _clearSelection(); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - document.addEventListener('keydown', _onKeyDown); -} - -/** - * Create a Cartesian3 position for a station, terrain-clamped when possible. - * Falls back to a fixed small height offset if terrain sampling is unsupported. - * @param {Object} station - Station info object with lat/lon. - * @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid. - */ -function createStationPosition(station) { - const lon = Number(station?.lon); - const lat = Number(station?.lat); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - - let height = POINT_HEIGHT_OFFSET_M; - // Sample terrain height so points sit on ground rather than at ellipsoid level - if (_viewer?.scene?.sampleHeightSupported) { - const carto = Cesium.Cartographic.fromDegrees(lon, lat); - const sampled = _viewer.scene.sampleHeight(carto); - if (Number.isFinite(sampled)) { - height = sampled + POINT_HEIGHT_OFFSET_M; - } - } - - return Cesium.Cartesian3.fromDegrees(lon, lat, height); -} - -/** - * Get or create the runtime tracking object for a city. - * @param {string} cityId - City identifier. - * @returns {{ stationKeys: Set }} City runtime object. - */ -function ensureCityRuntime(cityId) { - if (_cityRuntime.has(cityId)) return _cityRuntime.get(cityId); - const runtime = { stationKeys: new Set() }; - _cityRuntime.set(cityId, runtime); - return runtime; -} - -/** - * Ensure point primitives exist for all stations in a city. - * Creates new points for stations not yet rendered; skips existing ones. - * Respects the MAX_TOTAL_POINTS global cap to prevent GPU overload. - * @param {string} cityId - City identifier. - * @param {Map} stationMap - Parsed station info map for the city. - */ -function ensureCityPoints(cityId, stationMap) { - // Deferred debounce/fetch completions mutate point primitives after the - // camera settled — each commit needs one frame in idle mode. (perf wave 2 fix) - governorRequestRender('bikeshare-points'); - const runtime = ensureCityRuntime(cityId); - - for (const station of stationMap.values()) { - const key = stationKey(cityId, station.stationId); - if (_stationRenderMap.has(key)) { - runtime.stationKeys.add(key); - continue; - } - - // Enforce global point cap - if (_stationRenderMap.size >= MAX_TOTAL_POINTS) { - if (!_limitWarned) { - _limitWarned = true; - console.warn(`[Data:Bikeshare] Point cap reached (${MAX_TOTAL_POINTS}).`); - } - break; - } - - const position = createStationPosition(station); - if (!position) continue; - - // Add a point primitive with distance-based scale and translucency falloff - const point = _pointCollection.add({ - position, - pixelSize: capacityToPixelSize(station.capacity), - color: COLOR_NEUTRAL, - outlineColor: COLOR_OUTLINE, - outlineWidth: 1, - scaleByDistance: new Cesium.NearFarScalar(200, 1.35, 130000, 0.4), - translucencyByDistance: new Cesium.NearFarScalar(200, 1.0, 180000, 0.15), - disableDepthTestDistance: 2500, - id: key, - }); - - _stationRenderMap.set(key, { - key, - cityId, - stationId: station.stationId, - stationName: station.name, - point, - capacity: toNonNegativeInteger(station.capacity), - bikesAvailable: null, - docksAvailable: null, - isInstalled: station.isInstalled, - isRenting: station.isRenting, - isReturning: station.isReturning, - }); - - runtime.stationKeys.add(key); - } - - _count = _stationRenderMap.size; -} - -/** - * Remove all rendered point primitives for a city and clean up runtime state. - * Also clears any active selection if it belongs to this city. - * @param {string} cityId - City identifier to remove. - */ -function removeCityPoints(cityId) { - const runtime = _cityRuntime.get(cityId); - if (!runtime) return; - - // Clear selection if it belongs to the city being removed - if (_selectedKey && runtime.stationKeys.has(_selectedKey)) { - _clearSelection(); - } - - for (const key of runtime.stationKeys) { - const record = _stationRenderMap.get(key); - if (!record) continue; - _pointCollection.remove(record.point); - _stationRenderMap.delete(key); - } - - _cityRuntime.delete(cityId); - _count = _stationRenderMap.size; -} - -/** - * Apply real-time status data to rendered station points for a city. - * Updates each point's color (availability ratio) and pixel size (capacity), - * and refreshes the render record's cached availability fields. - * @param {string} cityId - City identifier. - * @param {Map} statusMap - Parsed station status map. - */ -function applyStatusToPoints(cityId, statusMap) { - governorRequestRender('bikeshare-status'); - const runtime = _cityRuntime.get(cityId); - if (!runtime) return; - - for (const key of runtime.stationKeys) { - const record = _stationRenderMap.get(key); - if (!record) continue; - - const status = statusMap.get(record.stationId) || null; - const capacity = resolveCapacity(record.capacity, status); - record.capacity = capacity; - - record.bikesAvailable = toNonNegativeInteger(status?.bikesAvailable); - record.docksAvailable = toNonNegativeInteger(status?.docksAvailable); - record.isInstalled = normalizeGbfsBool(status?.isInstalled, true); - record.isRenting = normalizeGbfsBool(status?.isRenting, true); - record.isReturning = normalizeGbfsBool(status?.isReturning, true); - - // Update visual properties based on current status - record.point.pixelSize = capacityToPixelSize(capacity); - record.point.color = statusToColor(status, capacity); - } -} - -/** - * Build a compact HUD detection ID string for a station. - * Truncates long names to 24 chars for readability. - * @param {Object} record - Render record from _stationRenderMap. - * @returns {string} Formatted label like "Station Name [5/20]". - */ -function buildDetectionId(record) { - const bikes = Number.isFinite(record.bikesAvailable) ? record.bikesAvailable : '?'; - const capacity = Number.isFinite(record.capacity) ? record.capacity : '?'; - // Render record stores the station name under `stationName` (see the render-map - // shape), so `record.name` was always undefined → every label read "Dock N". - const label = record.stationName || `Dock ${record.stationId}`; - // Truncate long station names to keep HUD readable - const short = label.length > 24 ? label.slice(0, 22) + '…' : label; - return `🚲 ${short} [${bikes}/${capacity}]`; -} - -/** - * Collect a sampled subset of visible stations for HUD detection overlay rendering. - * Uses a deterministic stride pattern controlled by options.seed and options.maxCount - * to avoid overcrowding the HUD while still providing broad coverage. - * @param {Object} [options] - * @param {number} [options.maxCount] - Maximum number of detectable objects to return. - * @param {number} [options.seed] - Seed for deterministic stride offset selection. - * @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>} - */ -function collectDetectableStations(options = {}) { - if (!_enabled || !_pointCollection || !_pointCollection.show || _stationRenderMap.size === 0) return []; - - // Gather all visible station records (include selected even though its point is hidden) - const records = []; - for (const record of _stationRenderMap.values()) { - const isSelected = record.key === _selectedKey; - if ((!record.point?.show && !isSelected) || !record.point?.position) continue; - records.push(record); - } - if (records.length === 0) return []; - - // Deterministic subsampling: pick every Nth station, offset by seed - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : records.length; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - const stride = Math.max(1, Math.ceil(records.length / maxCount)); - const start = ((seed % stride) + stride) % stride; - - const result = []; - for (let i = start; i < records.length; i += stride) { - const record = records[i]; - result.push({ - position: record.point.position, - sourceId: record.key, - id: buildDetectionId(record), - type: 'VEH', - skipLabel: record.key === _selectedKey, - }); - if (result.length >= maxCount) break; - } - - return result; -} - -/** - * Fully deactivate a single city: abort pending fetches and remove rendered points. - * @param {string} cityId - City identifier to deactivate. - */ -function deactivateCity(cityId) { - governorRequestRender('bikeshare-deactivate'); - abortInFlight(_inFlightInfo, cityId); - abortInFlight(_inFlightStatus, cityId); - removeCityPoints(cityId); -} - -/** Deactivate all currently active cities and clear the active set. */ -function deactivateAllCities() { - const cityIds = Array.from(_activeCityIds); - for (const cityId of cityIds) deactivateCity(cityId); - _activeCityIds.clear(); -} - -/** - * Activate a city: fetch station info, create point primitives, fetch status, - * and apply availability colors. Checks generation at each async boundary - * to bail out if the proximity context has changed. - * @param {string} cityId - City identifier to activate. - * @param {number} generation - Proximity generation at time of invocation. - */ -async function activateCity(cityId, generation) { - if (!_enabled || !_activeCityIds.has(cityId)) return; - - try { - const stationMap = await loadCityStationInfo(cityId, generation); - // Bail if context changed during fetch - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - - ensureCityPoints(cityId, stationMap); - - // Apply any cached status immediately for snappier initial rendering - const cachedStatus = _statusCache.get(cityId)?.statusMap; - if (cachedStatus) { - applyStatusToPoints(cityId, cachedStatus); - } - - const statusMap = await loadCityStationStatus(cityId, generation); - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - applyStatusToPoints(cityId, statusMap); - _lastUpdate = Date.now(); - _error = null; - } catch (error) { - if (error?.name === 'AbortError') return; - console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); - _error = 'GBFS fetch error'; - deactivateCity(cityId); - _activeCityIds.delete(cityId); - } finally { - _count = _stationRenderMap.size; - } -} - -/** - * Core proximity check: determines which cities are in camera range at the - * current altitude, deactivates out-of-range cities, and activates newly - * in-range ones. Increments the generation counter to invalidate stale work. - * @returns {Promise} - */ -async function runProximityCheck() { - if (!_enabled || !_viewer) return; - - const generation = ++_proximityGeneration; - const altitude = getCameraAltitude(_viewer); - // Altitude gate: disable all cities when camera is too high - if (!shouldActivateAtAltitude(altitude)) { - deactivateAllCities(); - return; - } - - const center = getCameraCenterLatLon(_viewer); - if (!center) return; - - // Diff active set against newly computed in-range set - const nextActive = computeInRangeCities(center); - for (const cityId of _activeCityIds) { - if (!nextActive.has(cityId)) { - deactivateCity(cityId); - } - } - - _activeCityIds = nextActive; - if (_activeCityIds.size === 0) { - _count = 0; - return; - } - - // Only activate cities that don't already have rendered points - const toActivate = []; - for (const cityId of _activeCityIds) { - if (!_cityRuntime.has(cityId)) toActivate.push(cityId); - } - if (toActivate.length === 0) return; - - await Promise.all(toActivate.map((cityId) => activateCity(cityId, generation))); -} - -/** Schedule a debounced proximity check after camera movement. */ -function scheduleProximityCheck() { - clearTimeout(_cameraDebounceTimer); - _cameraDebounceTimer = setTimeout(() => { - void runProximityCheck(); - }, CAMERA_DEBOUNCE_MS); -} - -/** Camera change event handler — triggers a debounced proximity check. */ -function onCameraChanged() { - if (!_enabled) return; - scheduleProximityCheck(); -} - -/** - * Bikeshare data layer object, conforming to the God's Eye View layer interface. - * Manages lifecycle (init/enable/disable/update) and provides detection and - * stats hooks for the HUD and UI systems. - * @type {Object} - */ -const bikeshareLayer = { - id: 'bikeshare', - name: 'Bikeshare', - icon: '🚲', - source: 'GBFS', - updateInterval: STATUS_POLL_MS, - - /** - * Initialize the bikeshare layer. Creates the point primitive collection, - * resets all internal state, and installs the click handler. - * Called once during app bootstrap. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - */ - init(viewer) { - _viewer = viewer; - _pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - viewer.scene.primitives.add(_pointCollection); - registerSpriteCollection('bikeshare', _pointCollection); - _pointCollection.show = false; - - _enabled = false; - _cameraDebounceTimer = null; - _cameraChangedAttached = false; - _altitudeGateEnabled = false; - _proximityGeneration = 0; - - _activeCityIds = new Set(); - _cityRuntime = new Map(); - _stationInfoCache = new Map(); - _statusCache = new Map(); - _inFlightInfo = new Map(); - _inFlightStatus = new Map(); - _stationRenderMap = new Map(); - _clickHandler = null; - _selectedKey = null; - _selectedEntity = null; - _count = 0; - _lastUpdate = null; - _loading = false; - _loadingOps = 0; - _error = null; - _limitWarned = false; - - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - - _installClickHandler(viewer); - - restoreSpriteOrder(viewer); - - console.log(`[Data:Bikeshare] Initialized with ${GBFS_CITY_REGISTRY.length} cities`); - }, - - /** - * Enable the bikeshare layer. Shows points, attaches the camera listener, - * and triggers an initial proximity check. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - */ - enable(viewer) { - _enabled = true; - _error = null; - _pointCollection.show = true; - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true); - _installClickHandler(viewer); - // Pick-ownership (H2): station point ids are string render-map keys. - registerPickOwner('bikeshare', (pickedId) => _stationRenderMap.has(pickedId)); - - if (!_cameraChangedAttached) { - viewer.camera.changed.addEventListener(onCameraChanged); - viewer.camera.percentageChanged = Math.min(viewer.camera.percentageChanged || 1, 0.05); - _cameraChangedAttached = true; - } - - void runProximityCheck(); - restoreSpriteOrder(viewer); - }, - - /** - * Disable the bikeshare layer. Hides points, removes event listeners, - * aborts all pending fetches, and tears down all city data. - * @param {Cesium.Viewer} viewer - Cesium viewer instance. - */ - disable(viewer) { - _enabled = false; - _proximityGeneration++; - _altitudeGateEnabled = false; - clearTimeout(_cameraDebounceTimer); - _cameraDebounceTimer = null; - _clearSelection(); - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('bikeshare'); - - if (_cameraChangedAttached) { - viewer.camera.changed.removeEventListener(onCameraChanged); - _cameraChangedAttached = false; - } - - abortAllInFlight(); - deactivateAllCities(); - _cityRuntime.clear(); - _pointCollection.show = false; - _count = 0; - _loading = false; - _loadingOps = 0; - }, - - /** - * Periodic update tick — re-fetches station status for all active cities - * and refreshes point colors/sizes. Called by the layer manager at - * STATUS_POLL_MS intervals. - * @returns {Promise} - */ - async update() { - if (!_enabled || _activeCityIds.size === 0) return; - - const generation = _proximityGeneration; - const cityIds = Array.from(_activeCityIds); - await Promise.all(cityIds.map(async (cityId) => { - try { - const statusMap = await loadCityStationStatus(cityId, generation); - if (!_enabled || !_activeCityIds.has(cityId) || generation !== _proximityGeneration) return; - applyStatusToPoints(cityId, statusMap); - } catch (error) { - if (error?.name === 'AbortError') return; - console.warn(`[Data:Bikeshare] ${cityId} status update error:`, error); - _error = 'GBFS status update failed'; - } - })); - - _count = _stationRenderMap.size; - if (_count > 0) _lastUpdate = Date.now(); - }, - - /** - * Return a sampled array of detectable station objects for HUD overlay rendering. - * @param {Object} [options] - Sampling options (maxCount, seed). - * @returns {Array<{ position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean }>} - */ - getDetectableObjects(options = {}) { - return collectDetectableStations(options); - }, - - /** - * Return current layer statistics for the UI status display. - * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }} - */ - getStats() { - const stats = { - count: _count, - lastUpdate: _lastUpdate, - loading: _loading, - }; - if (_loading) { - stats.loadingLabel = _activeCityIds.size > 0 - ? `syncing ${_activeCityIds.size} city feeds...` - : 'scanning nearby systems...'; - } - if (_error) stats.error = _error; - return stats; - }, - - /** Permanently release primitives, handlers, and the selected host source. */ - destroy(viewer) { - if (_enabled) this.disable(viewer); - else { - _clearSelection(); - _overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, false); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('bikeshare'); - } - if (_cameraChangedAttached) { - viewer.camera.changed.removeEventListener(onCameraChanged); - _cameraChangedAttached = false; - } - abortAllInFlight(); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - _viewer = null; - }, -}; - -/** - * Global keydown handler — deselects the current station on Escape. - * @param {KeyboardEvent} e - Keyboard event. - */ -function _onKeyDown(e) { - if (e.key === 'Escape' && _selectedKey) { - _clearSelection(); - } -} - -/** Seed a selected-station runtime record while still exercising real select/clear paths. */ -export function _setBikeshareSelectionStateForTest({ viewer, key, record, overlayHost }) { - _viewer = viewer; - _stationRenderMap = new Map([[key, record]]); - _selectedKey = null; - _selectedEntity = null; - _overlayHost = overlayHost || DEFAULT_OVERLAY_HOST; -} - -/** Exercise the production selection path in focused runtime tests. */ -export function _selectBikeshareStationForTest(key) { - _selectStation(key); -} - -/** Exercise the production clear path and restore the production host seam. */ -export function _clearBikeshareSelectionForTest() { - _clearSelection(); - _overlayHost = DEFAULT_OVERLAY_HOST; -} - -export default bikeshareLayer; +export const createBikeshareSelectedOverlayEntry = + layer.createBikeshareSelectedOverlayEntry; +export const _setBikeshareSelectionStateForTest = + layer._setBikeshareSelectionStateForTest; +export const _selectBikeshareStationForTest = + layer._selectBikeshareStationForTest; +export const _clearBikeshareSelectionForTest = + layer._clearBikeshareSelectionForTest; +export { + BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, + BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, +} from '../layers/bikeshare/index.js'; +export default layer; diff --git a/src/data/cctv.js b/src/data/cctv.js index 884323477..6f86880fa 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,4825 +1,89 @@ -/** - * @module cctv - * - * CCTV camera data layer for God's Eye View. - * - * Architecture: - * - Camera catalog: built from seed definitions (CAMERA_SEEDS) merged with live - * sources fetched from the backend (/api/cctv/sources). Each camera record - * holds a base pose, a calibration offset, computed intrinsics/extrinsics, - * and an anchor position on the globe. - * - * - Coverage geometry (v2): a true pitched frustum pyramid per camera — 4 - * corner rays from the mount to the far-plane corners plus the closed - * far-plane rectangle (5 polylines), rendered as Cesium entities with - * neighbor-limited visibility. All geometry is pure pose math - * (computeFrustumGeometry) — zero steady-state scene queries. - * - * - Monitor plane (v2): the live frame renders on a plane entity capping the - * far end of the frustum ("monitor at the end of the cone"), oriented - * perpendicular to the view axis (static — never billboarded). Video feeds - * bind the HTMLVideoElement directly (Cesium updates video textures - * per-frame); image feeds alternate two offscreen canvases so the texture - * re-uploads on every repaint tick (<=1Hz). One live plane at a time. On - * activation a single scene.pickFromRay obstruction probe (§9.1 — the ONLY - * raycast in the subsystem) clamps the plane's range short of the first - * tile hit so the end cap never clips into buildings. - * - * - Ambient card tier (2026-07-29 design): the LOD-selected nearby static - * cameras (cctvLod.js — zoom-scaled 16/24/32 budget, eviction grace) - * additionally get a screen-space thumbnail card (cctvCards.js) showing - * their latest paced static frame. Reselection is camera.moveEnd-driven - * (never per frame); frame fetches go through a global 1-per-second gate; - * camera icons stay visible at every zoom (cards annotate, never replace). - * The active camera keeps the monitor plane and is excluded from the card - * ring by default. An opt-in presentation option can also publish its - * protected thumbnail without changing the ambient quota. - * - * - Health sync: periodic fetch of /api/cctv/health to update per-camera - * source status shown in the UI. - * - * - Auto-hop: timed camera cycling with view-context awareness (snaps to - * nearest camera when the viewer pans to a new region). - * - * All mutable state is module-scoped. The exported `cctvLayer` object - * implements the standard layer interface (init/enable/disable/update/destroy) - * plus CCTV-specific methods (selectCamera, cycleCamera, focusNearest, etc.). - */ -import * as Cesium from 'cesium'; -import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; -import { - CCTV_ACTIVATION_RESULT, - activateCctvCameraFromWorldClick, -} from '../cctvFocusRequest.js'; -import { bindTrackingClickGesture, isTrackingClickGesture } from './trackingClickGesture.js'; -import { - clearOverlaySource, - hitTestWorldOverlay, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { CITY_POIS } from '../locations.js'; -import { - registerPickOwner, - resolvePickId, - unregisterPickOwner, -} from './pickRegistry.js'; -import { resolveEllipsoidalGround } from './terrainHeights.js'; -import { cachedGroundFloor, resolveGroundFloorCells, warmGroundFloor } from './groundFloor.js'; -import { sampleMeshFloorCells } from './meshFloorSampler.js'; -import { horizonOccluder } from './iconOrientation.js'; -import { cameraHue, viewshedColors, createFrustumVolumePrimitive } from './cctvViewshed.js'; -import { createCalibrationGizmo, GIZMO_ID_PREFIX } from './cctvGizmo.js'; -import { - CCTV_AMBIENT_CARD_MAX, - applyEvictionGrace, - selectCctvLod, - staticFrameRefreshMs, -} from './cctvLod.js'; -import { - CCTV_CARD_FETCH_BURST_LIMIT, - CCTV_CARD_FETCH_BURST_SPACING_MS, - CCTV_FRAME_CANVAS_W, - CCTV_FRAME_CANVAS_H, - applyFrameResult, - cardFetchPolicy, - createCctvThumbnailOverlayEntry, - createFrameSlot, - declutterCctvCards, - CCTV_OVERLAY_SOURCE_ID, - planFrameCachePrune, - frameFetchDue, -} from './cctvCards.js'; -import { - advanceSpriteFocus, - focusAlphaNeedsWrite, - focusNowMs, - focusPassIsNeeded, - getFocusTarget, - onFocusTargetAppear, -} from './focusDeemphasis.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +import { createCctvSource } from '../layers/cctv/source.js'; +import { createCctvLayer } from '../layers/cctv/index.js'; +import * as sprites from './spriteOrder.js'; +import * as activation from '../cctvFocusRequest.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as locations from '../locations.js'; +import * as picking from './pickRegistry.js'; +import * as terrain from './terrainHeights.js'; +import * as ground from './groundFloor.js'; +import * as mesh from './meshFloorSampler.js'; +import * as focus from './focusDeemphasis.js'; +import * as render from '../renderGovernor.js'; -// --------------------------------------------------------------------------- -// API endpoints -// --------------------------------------------------------------------------- -const FRAME_ENDPOINT = '/api/cctv/frame'; -const SOURCE_ENDPOINT = '/api/cctv/sources'; -const HEALTH_ENDPOINT = '/api/cctv/health'; -const MEDIA_ENDPOINT = '/api/cctv/media'; - -// --------------------------------------------------------------------------- -// Timing and geometry constants -// --------------------------------------------------------------------------- -const DEFAULT_UPDATE_INTERVAL_MS = 10000; -const MIN_AUTO_HOP_SEC = 8; -const MAX_AUTO_HOP_SEC = 90; -const HEALTH_SYNC_INTERVAL_MS = 7000; -const ACTIVE_FRAME_REFRESH_MS = 10000; -const IDLE_FRAME_REFRESH_MS = 60000; -const PROJECTION_ACTIVE_REFRESH_MS = 10000; -const PROJECTION_IDLE_REFRESH_MS = 60000; -const PROJECTION_CANVAS_WIDTH = 1920; -const PROJECTION_CANVAS_HEIGHT = 1080; -// Downsample grid for the unchanged-frame signature (drawProjectionFrame). -// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a -// full-resolution compare would touch. -const FRAME_SIGNATURE_W = 64; -const FRAME_SIGNATURE_H = 36; -const COVERAGE_NEIGHBOR_LIMIT = 14; -const COVERAGE_NEIGHBOR_RADIUS_KM = 1.8; -// Staggered geometry/frame loading: ground-sampled coverage geometry is -// refined in small batches (active camera first, then nearest-to-viewer) so -// enabling the layer never raycasts every camera in a single frame. -const GEO_LOAD_BATCH_SIZE = 4; -const GEO_LOAD_BATCH_DELAY_MS = 120; -const GEO_TRACKING_BATCH_SIZE = 2; -const GEO_TRACKING_BATCH_DELAY_MS = 250; -const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300; -const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10; -// Throttle for placeholder repaints — the projection RAF loop must not -// re-fill a 1080p canvas on every frame while a feed image is still loading. -const PLACEHOLDER_REPAINT_MS = 750; -// v1 key is retired dead data (owner decision #3, §9.3 — WIPE CLEAN, no -// legacy import): kept here only as a documented constant so nothing ever -// re-reads it by accident. Exported for the unit suite's "v1 is ignored" -// assertion; there is NO read path for this key anywhere in the module. -export const CCTV_CALIBRATION_STORAGE_KEY_V1 = 'godsEyeView.cctv.calibration.v1'; -/** v2 store key. Entries: { values: <7-field calibration offsets>, source: 'manual', savedAt: }. */ -export const CCTV_CALIBRATION_STORAGE_KEY_V2 = 'godsEyeView.cctv.calibration.v2'; -// H5: throttle for double-buffered canvas texture swaps (<=1Hz; each swap is a -// full 1080p texture re-upload because Cesium re-uploads only on a NEW image -// object reference). -const PROJECTION_TEXTURE_SWAP_MS = 1000; -const PROJECTION_VERT_ASPECT = PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT; -// V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink -// below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury -// the monitor plane in the 3D tiles. Exported for the unit suite. -export const FRUSTUM_GROUND_CLEARANCE_M = 2; -/** Public result codes for explicit CCTV camera flights. */ -export const CCTV_FOCUS_RESULT = Object.freeze({ - FOCUSED: 'focused', - NO_ACTIVE_CAMERA: 'no-active-camera', - TRACKING_HOLDS_VIEW: 'tracking-holds-view', - COCKPIT_ACTIVE: 'cockpit-active', +const layer = createCctvLayer({ + source: createCctvSource(), + services: { + sprites, + activation, + overlays, + locations, + picking, + terrain, + ground, + mesh, + focus, + render, + }, }); -// §9.1 activation obstruction probe: clamp the plane's effective range to just -// short of the first pickFromRay hit along the frustum axis, with a floor so a -// point-blank obstruction never collapses the frustum to zero. The floor is -// the old H6 monitor's 8-15 m distance band: small enough that a pitched-down -// camera whose axis meets the street ~25 m out still clamps SHORT of the hit -// (a larger floor would push the plane back through the obstruction). -const PROBE_CLEARANCE_M = 4; -const PROBE_MIN_RANGE_M = 12; -// Bounded wait for the enable-time ground-prior batch: warm proxy disk cache -// resolves in milliseconds; a cold/slow upstream must never hang layer init, -// so past this budget init proceeds on catalog fallbacks and the batch applies -// post-hoc (applyLateGroundPriors) when it lands. -const GROUND_PRIOR_INIT_WAIT_MS = 8000; -/** Default calibration offsets — all zeroed, range scale 1x. */ -const DEFAULT_CAMERA_CALIBRATION = Object.freeze({ - offsetNorthM: 0, - offsetEastM: 0, - headingDeg: 0, - pitchDeg: 0, - fovDeg: 0, - rangeScale: 1, - heightM: 0, -}); - -/** - * Returns whether a calibration patch moves the camera's ground anchor. - * Rotational, optical, range, and manual-height edits preserve the existing - * ground reference; only north/east translation needs a new floor. - * @param {Object|null|undefined} patch - * @returns {boolean} - */ -export function calibrationPatchMovesAnchor(patch) { - if (!patch || typeof patch !== 'object') return false; - return Object.prototype.hasOwnProperty.call(patch, 'offsetNorthM') || - Object.prototype.hasOwnProperty.call(patch, 'offsetEastM'); -} - -/** Base64-encoded SVG camera icon for billboard rendering. */ -const CAMERA_ICON = (() => { - const svg = ` - - - - - - - - - - - - - - - `; - return 'data:image/svg+xml;base64,' + btoa(svg); -})(); - -/** - * Seed camera definitions used when no live sources are available. - * Each seed references a city from CITY_POIS and a POI index within that city, - * plus offsets to place the camera near the POI. - */ -const CAMERA_SEEDS = [ - { id: 'nyc-midtown-w', cityId: 'nyc', poiIndex: 1, label: 'Midtown West @ 34th', offsetNorthM: 120, offsetEastM: -70, headingDeg: 206, fovDeg: 74, rangeM: 880, elevationM: 26 }, - { id: 'nyc-wtc-n', cityId: 'nyc', poiIndex: 2, label: 'WTC North Plaza', offsetNorthM: 95, offsetEastM: 34, headingDeg: 164, fovDeg: 68, rangeM: 760, elevationM: 32 }, - { id: 'nyc-times-square-ne', cityId: 'nyc', poiIndex: 1, label: 'Times Sq Northeast', offsetNorthM: 230, offsetEastM: 120, headingDeg: 218, fovDeg: 66, rangeM: 640, elevationM: 24 }, - - { id: 'sf-market-5th', cityId: 'sf', poiIndex: 2, label: 'Market & 5th', offsetNorthM: -160, offsetEastM: 80, headingDeg: 320, fovDeg: 70, rangeM: 780, elevationM: 20 }, - { id: 'sf-financial-district', cityId: 'sf', poiIndex: 1, label: 'SF Financial Core', offsetNorthM: 110, offsetEastM: 52, headingDeg: 205, fovDeg: 72, rangeM: 760, elevationM: 24 }, - - { id: 'tokyo-shibuya-scramble', cityId: 'tokyo', poiIndex: 4, label: 'Shibuya Crossing', offsetNorthM: 180, offsetEastM: 46, headingDeg: 18, fovDeg: 82, rangeM: 640, elevationM: 30 }, - { id: 'tokyo-ginza-core', cityId: 'tokyo', poiIndex: 0, label: 'Ginza Core', offsetNorthM: -180, offsetEastM: 150, headingDeg: 245, fovDeg: 70, rangeM: 690, elevationM: 28 }, - { id: 'tokyo-asakusa-n', cityId: 'tokyo', poiIndex: 3, label: 'Asakusa North Gate', offsetNorthM: 110, offsetEastM: -65, headingDeg: 192, fovDeg: 68, rangeM: 620, elevationM: 24 }, - - { id: 'london-city-a1', cityId: 'london', poiIndex: 4, label: 'City Cluster A1', offsetNorthM: 80, offsetEastM: 65, headingDeg: 220, fovDeg: 71, rangeM: 720, elevationM: 27 }, - { id: 'london-soho-core', cityId: 'london', poiIndex: 2, label: 'Soho Core', offsetNorthM: 210, offsetEastM: 120, headingDeg: 206, fovDeg: 70, rangeM: 700, elevationM: 22 }, - - { id: 'paris-rivoli', cityId: 'paris', poiIndex: 4, label: 'Rue de Rivoli', offsetNorthM: 55, offsetEastM: 85, headingDeg: 248, fovDeg: 66, rangeM: 640, elevationM: 22 }, - { id: 'paris-champs-n', cityId: 'paris', poiIndex: 1, label: 'Champs-Élysées North', offsetNorthM: 130, offsetEastM: -38, headingDeg: 175, fovDeg: 68, rangeM: 700, elevationM: 26 }, - - { id: 'dc-mall-center', cityId: 'dc', poiIndex: 1, label: 'National Mall Center', offsetNorthM: 120, offsetEastM: 20, headingDeg: 258, fovDeg: 78, rangeM: 940, elevationM: 24 }, - { id: 'dc-pentagon-s', cityId: 'dc', poiIndex: 3, label: 'Pentagon South', offsetNorthM: -100, offsetEastM: 92, headingDeg: 14, fovDeg: 66, rangeM: 620, elevationM: 21 }, - - { id: 'dubai-difc-loop', cityId: 'dubai', poiIndex: 4, label: 'DIFC Loop', offsetNorthM: 92, offsetEastM: -45, headingDeg: 196, fovDeg: 70, rangeM: 720, elevationM: 26 }, - { id: 'dubai-downtown-east', cityId: 'dubai', poiIndex: 0, label: 'Downtown East', offsetNorthM: -130, offsetEastM: 190, headingDeg: 322, fovDeg: 72, rangeM: 760, elevationM: 28 }, - - { id: 'austin-congress-s', cityId: 'austin', poiIndex: 0, label: 'Congress Southbound', offsetNorthM: -165, offsetEastM: 40, headingDeg: 12, fovDeg: 74, rangeM: 760, elevationM: 24 }, - { id: 'austin-downtown-west', cityId: 'austin', poiIndex: 1, label: 'Downtown West', offsetNorthM: -120, offsetEastM: -160, headingDeg: 120, fovDeg: 69, rangeM: 700, elevationM: 20 }, -]; - -// --------------------------------------------------------------------------- -// Visual style constants -// --------------------------------------------------------------------------- -const IDLE_CAMERA_COLOR = Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.88); -const ACTIVE_CAMERA_COLOR = Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); -const IDLE_COVERAGE_COLOR = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); -const IDLE_COVERAGE_CENTER_MUTED = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.2); -const IDLE_COVERAGE_EDGE_MUTED = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.18); -const ACTIVE_COVERAGE_EDGE = Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.58); -const ACTIVE_COVERAGE_CENTER = Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.82); -// H6: dimmer depth-fail materials let the active frustum wireframe read -// through buildings while in monitor fallback mode. -const ACTIVE_COVERAGE_EDGE_DEPTHFAIL = Cesium.Color.fromCssColorString('#8dff87').withAlpha(0.18); -const ACTIVE_COVERAGE_CENTER_DEPTHFAIL = Cesium.Color.fromCssColorString('#d7ff8d').withAlpha(0.26); -const PLANE_OUTLINE_COLOR = Cesium.Color.fromCssColorString('#6be8ff').withAlpha(0.55); - -// --------------------------------------------------------------------------- -// Module-scoped mutable state -// --------------------------------------------------------------------------- -let _viewer = null; -let _billboards = null; -let _records = []; -let _recordById = new Map(); -let _coverageEntities = []; -let _projectionEntities = []; -let _enabled = false; -let _activeCameraId = null; -let _coverageMode = 'on'; // 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes) -let _showProjection = true; -let _autoHop = false; -// An explicit empty-space deselect keeps AUTO HOP configured but prevents its -// timer from silently choosing a replacement. A later explicit activation or -// AUTO HOP toggle-on releases the hold. -let _autoHopSuspended = false; -let _autoHopSec = 18; -let _lastHopAt = 0; -let _lastViewContext = ''; -let _clickHandler = null; -let _count = 0; -let _lastUpdate = null; -let _lastHealthSyncAt = 0; -let _lastError = null; -let _healthById = new Map(); -let _calibrationById = new Map(); -let _listeners = new Set(); -let _projectionRaf = 0; -let _removeFocusAppearListener = null; -let _lastFocusStyleAt = 0; -/** Icons whose animated emphasis remains outside the 1.0 deadband. */ -let _activeFocusStyleCount = 0; -const _scratchFocusScreen = new Cesium.Cartesian2(); -// Staggered geometry-load queue state (see startGeometryLoadQueue). -let _geoQueue = []; -let _geoQueueTimer = 0; -let _geoLoading = false; -let _geoLoadTotal = 0; -let _geoLoadDone = 0; -let _geoProgressNotifier = null; -// One-shot completion latch for shared floor resolution: the enable-time queue -// can drain while DEM cells or 3D tiles are still loading. The first update() -// tick that sees projectionTilesReady() re-enqueues unresolved records ONCE; -// shared mesh cells remain one-shot and idle ticks stay sample-free. Reset by -// startGeometryLoadQueue so each enable-time drain gets its own completion pass. -let _tilesReadyReenqueued = false; -// Calibration ADJUST mode (viewshed/gizmo design §3c): while true, the active -// camera renders the direct-manipulation gizmo. Reset on layer disable. -let _calibrationMode = false; -let _gizmo = null; -let _lastTransientNotifyAt = 0; -// Cached handle on the active Google Photorealistic 3D Tileset, discovered -// lazily from scene.primitives. Shared mesh-floor sampling is gated on its -// tilesLoaded flag so a coarse-LOD miss is never baked in. Cleared when the -// tileset is destroyed / the layer tears down. -let _activeTileset = null; -// Task 5: last surface regime the record geometry was recomputed for. The -// map-stack change listener compares the CURRENT regime (derived live from -// scene.globe.show) against this so bing→osm switches (same 'terrain-globe' -// regime) don't trigger a pointless full-catalog rewrite. -let _lastAppliedRegime = null; -// Task 5: window listener handle for the 'gev:map-stack-changed' CustomEvent -// main.js dispatches from MapStackController's onChange (removed in destroy). -let _mapStackListener = null; -// Field-test fix (2026-07-06): camera.moveEnd handle for the horizon-culling -// pass (removed in destroy). Event-driven only — never a per-frame loop, so -// the zero-steady-state-work invariant holds. -let _horizonCullListener = null; -// Ambient card tier state (2026-07-29 design). The card set is rebuilt only -// on moveEnd/enable/activation (refreshAmbientCards); frame slots are STABLE -// objects shared with the overlay host so landed frames appear without an -// entry rebuild. -let _cardIds = new Set(); -/** @type {Map} */ -let _cardGraceState = new Map(); -/** @type {Map} */ -let _cardFrameSlots = new Map(); -let _cardFetchTimer = 0; -/** In-flight card-frame fetch count (burst allows up to 4, steady is 1). */ -let _cardFetchInFlightCount = 0; -/** @type {Set} in-flight fetches, detached on teardown. */ -const _cardFetchImages = new Set(); -/** @type {Set} camera ids with an in-flight fetch (no double-fetch). */ -const _cardFetchPendingIds = new Set(); -let _cardFetchCount = 0; -let _cardLastFetchAt = 0; -let _cardMinFetchSpacingMs = null; -/** Pacer mode telemetry: 'burst' during cold fill, 'steady' after. */ -let _cardFetchMode = 'steady'; -/** - * Card budget while the staggered geometry drain is running — the raised - * 20/28/40 tiers resume when loading completes (see refreshAmbientCards). - */ -const CCTV_AMBIENT_CARD_DRAIN_CAP = 16; -// Global static-frame pacing (owner finding 3): the pacer ticks at the burst -// spacing (250 ms) but cardFetchPolicy gates launches — cold fill (selected -// cards still missing their FIRST frame) allows up to 4 in-flight fetches at -// 250 ms spacing; steady state keeps the salvaged Part C gate of at most one -// request per second with an in-flight fetch blocking the tick, so slow -// responses only lower the rate. -const CARD_FETCH_TICK_MS = CCTV_CARD_FETCH_BURST_SPACING_MS; -// In-view margin so cards whose anchors sit just beyond an edge don't churn -// while the operator makes minor camera adjustments (Part C recordIsInView). -const CARD_VIEW_MARGIN = 0.06; -/** Card leader gap: clears the 24px icon (12px half + breathing room). */ -const CARD_GAP_PX = 16; -const CCTV_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: CCTV_AMBIENT_CARD_MAX, - collisionCapacity: CCTV_AMBIENT_CARD_MAX, - moving: true, - solveIntervalMs: 125, -}); -export const CCTV_PROJECTION_OVERLAY_SOURCE_ID = 'cctv-projection'; -export const CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 1, - collisionCapacity: 0, - moving: false, -}); -const DEFAULT_CCTV_OVERLAY_HOST = Object.freeze({ - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, -}); -let _cctvOverlayHost = DEFAULT_CCTV_OVERLAY_HOST; -let _projectionOverlayOwnerId = null; -/** - * Product presentation option. Shipped behavior keeps the active camera's - * thumbnail absent because its monitor plane is the active representation. - */ -let _activeCameraCardEnabled = false; -// Hover-summoned card (owner round 2, item B): pointing at a cardless camera -// icon shows its card immediately as a PINNED entry (budget-exempt, top -// draw-pass declutter priority). -/** Min spacing between hover scene.pick calls (event-driven, user gesture). */ -const HOVER_PICK_THROTTLE_MS = 120; -/** How long the hover card lingers after the pointer leaves the icon. */ -const HOVER_RELEASE_MS = 1_000; -/** Camera id currently holding the hover-summoned pinned card (or null). */ -let _hoverCardId = null; -let _hoverReleaseTimer = 0; -let _hoverLastPickAt = 0; - -/** Test seam: republishes host entries through the real push path, so tests - * can observe the pristine module default without touching the setter. */ -export function _pushAmbientCardEntriesForTest() { - pushAmbientCardEntries(); -} - -/** Test seam for exercising real layer lifecycle paths without a DOM host. */ -export function _setCctvOverlayHostForTest(host = null) { - _cctvOverlayHost = host ? { ...DEFAULT_CCTV_OVERLAY_HOST, ...host } : DEFAULT_CCTV_OVERLAY_HOST; - _projectionOverlayOwnerId = null; -} - -/** - * Build the protected label associated with one active monitor plane. - * @param {{cameraId: string, name: string, position: Cesium.Cartesian3|Function}} input - * @returns {Object} Shared-host presentation entry. - */ -export function createCctvProjectionOverlayEntry({ cameraId, name, position }) { - return { - id: String(cameraId), - position, - variant: 'selected', - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-card', - priority: Number.MAX_SAFE_INTEGER - 1, - title: String(name || cameraId || 'CAMERA'), - details: [], - accent: '#6be8ff', - interactive: false, - gapPx: 6, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** - * Configures optional CCTV card presentation without changing card density. - * The active-camera thumbnail defaults OFF, preserving the shipped behavior - * where the monitor plane is the camera's sole active representation. - * - * @param {Object} [options] - * @param {boolean} [options.activeCameraCardEnabled=false] - * @returns {{activeCameraCardEnabled:boolean}} - */ -export function setCctvCardPresentationOptions({ activeCameraCardEnabled = false } = {}) { - _activeCameraCardEnabled = activeCameraCardEnabled === true; - if (_enabled) pushAmbientCardEntries(); - _viewer?.scene?.requestRender?.(); - return { activeCameraCardEnabled: _activeCameraCardEnabled }; -} -// True between camera.moveStart and moveEnd — hover picking pauses while the -// camera is in motion (picks during a flight would fight the reselection). -let _cameraMoving = false; -let _moveStartListener = null; - -/** - * Converts degrees to radians. - * @param {number} deg - * @returns {number} - */ -function toRad(deg) { - return Cesium.Math.toRadians(deg); -} - -/** - * Normalizes a heading angle to the [0, 360) range. - * @param {number} deg - * @returns {number} - */ -function normalizeHeading(deg) { - let v = deg % 360; - if (v < 0) v += 360; - return v; -} - -/** - * Clamps a value to [min, max]. - * @param {number} value - * @param {number} min - * @param {number} max - * @returns {number} - */ -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -/** - * Normalizes an angle to the (-180, 180] range. - * @param {number} deg - * @returns {number} - */ -function normalizeSignedAngle(deg) { - let value = deg % 360; - if (value > 180) value -= 360; - if (value <= -180) value += 360; - return value; -} - -/** - * Returns the absolute angular difference between two headings in degrees. - * @param {number} aDeg - * @param {number} bDeg - * @returns {number} Value in [0, 180]. - */ -function angularDeltaAbs(aDeg, bDeg) { - return Math.abs(normalizeSignedAngle(aDeg - bDeg)); -} - -/** - * Task 5 (height-datum fix): maps the scene's `globe.show` flag to the surface - * regime key the per-camera ground cache is keyed by (spec §2 "cache by - * surface regime", collapsed to two keys — ion World Terrain and Re:Earth - * globe terrain get the same handling): - * - * - `google-3d` — photoreal stack: globe hidden, the visible Google 3D - * tileset IS the surface → one-shot scene sampling refines. - * - `terrain-globe` — any globe stack: the Re:Earth point-height prior IS the - * resolution (zero scene queries). - * - * Only an explicit `false` (the photoreal stack hides the globe) selects - * `google-3d`; undefined/null (no viewer / torn down) must fall to the regime - * that never touches the scene. Pure — exported for the unit suite. - * @param {boolean|undefined|null} globeShow - `viewer.scene.globe.show`. - * @returns {'google-3d'|'terrain-globe'} - */ -export function surfaceRegimeKey(globeShow) { - return globeShow === false ? 'google-3d' : 'terrain-globe'; -} - -/** - * Normalizes a raw feed-type string to a canonical type (image, mjpeg, mp4, hls, webm). - * @param {string|*} value - Raw feed type from source config. - * @returns {string} Canonical feed type. - */ -function normalizeFeedType(value) { - const raw = String(value || '').trim().toLowerCase(); - if (!raw) return 'image'; - if (raw === 'mjpg') return 'mjpeg'; - if (raw === 'jpeg') return 'image'; - if (raw === 'jpg') return 'image'; - if (raw === 'video') return 'mp4'; - if (raw === 'stream') return 'hls'; - if (raw === 'png') return 'image'; - if (raw === 'gif') return 'image'; - return raw; -} - -/** - * Returns true if the feed type requires a